This is the abridged developer documentation for Grona
# Build with Grona
> Everything your engineering team needs to run Grona campaigns on your website, in your mobile apps and from your own systems.
## Choose where Grona runs [Section titled “Choose where Grona runs”](#choose-where-grona-runs) Website Add one script to your site, verify it, and learn how targeting, preview, consent and performance work. [Website integration](/web/) Mobile SDKs Native Android and iOS SDKs. Grona decides what each shopper sees; your app keeps rendering its own products. [Android and iOS](/mobile/) Public API Create campaigns, set them live and open the visual editor from your own platform, server to server. [API reference](/api/) Changelog Every change to the web platform, the Public API and the SDKs, newest first. [What changed](/changelog/) ## Popular pages [Section titled “Popular pages”](#popular-pages) [Install the Grona script](/web/install/add-the-script/)The snippet, where it goes, and how to check it loads. [Content Security Policy](/web/configure/content-security-policy/)The exact directives Grona needs, ready to copy. [Install the Android SDK](/mobile/android/install/)Gradle setup and requirements. [Install the iOS SDK](/mobile/ios/install/)Swift Package Manager setup and requirements. [Create a campaign](/api/reference/operations/create-campaign/)The Public API call that provisions a campaign. [Troubleshooting](/web/help/troubleshooting/)Fixes for the problems teams hit most often.
# Public API overview
> Base URL, authentication, the response envelope and the resources of the Grona Public API v1.
The Grona Public API v1 lets your backend run website campaigns from your own product. You can create a campaign, open it in the Grona visual editor, preview it, set it live and check that the Grona snippet is installed, all over HTTPS. The API also returns grants for the Grona mobile SDKs. Use it when campaigns should start in your systems rather than in the Grona dashboard. Available on request The Public API is available on request. Email to get access. ## Base URL [Section titled “Base URL”](#base-url) Every request goes to `https://api.grona.ai`, and every path starts with `/v1`:
```text
https://api.grona.ai/v1/campaigns
```
Request bodies are JSON, sent with `Content-Type: application/json`. The version in the path changes only for breaking changes, as described in [Versioning and deprecation](/api/versioning/). ## Server to server only [Section titled “Server to server only”](#server-to-server-only) Every call carries your workspace secret key, either as `Authorization: Bearer grona_sk__` or as `X-API-Key: grona_sk__`. The key decides which workspace the call acts on, so the workspace never appears in a URL or a request body. Call the API from your servers only. A secret key in a browser page or a mobile app can be read by anyone who opens it. [Authentication](/api/authentication/) covers how to get a key and how to keep it safe. ## The response envelope [Section titled “The response envelope”](#the-response-envelope) Every Public API response uses the same envelope, except that a `429` or `503` body may not. A successful call returns `data` and `meta`:
```json
{
"data": { "id": "671a3c90e4b0a1f2c3d40011", "status": "paused" },
"meta": { "workspaceId": "65e8a1c47d2f9b03e6a15c42" }
}
```
`meta.workspaceId` is the workspace resolved from your key. List responses add `meta.count`. A failed call returns `error` with a stable `code` and a human-readable `message`:
```json
{
"error": {
"code": "conflict",
"message": "externalId `CMP-1024` is already used by another campaign."
}
}
```
Branch on `error.code` and the HTTP status, never on the message. [Errors](/api/errors/) lists every code and how to fix it. ## Resources [Section titled “Resources”](#resources) | Resource | Endpoints | What you do with it | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | Campaigns | [Create](/api/reference/operations/create-campaign/), [list](/api/reference/operations/list-campaigns/), [get](/api/reference/operations/get-campaign/), [update](/api/reference/operations/update-campaign/), [set status](/api/reference/operations/set-campaign-status/), [delete](/api/reference/operations/delete-campaign/) | Create website campaigns, A/B tests and multi-page campaigns, then set them live or pause them. | | Editor links | [Create a link](/api/reference/operations/create-edit-link/), [check a link](/api/reference/operations/validate-edit-link/) | Open a variation in the Grona visual editor from your product. | | Websites | [Verify script](/api/reference/operations/verify-script/) | Check that the Grona snippet is on a page. | | Mobile grants | [Fetch grants](/api/reference/operations/issue-grants/) | Fetch signed grants for a shopper and hand them to your app. | The [endpoint reference](/api/reference/) has every field, status code and a code sample for each operation. It also lists the endpoints the Grona Android and iOS SDKs call. Your code does not call those. The same reference is available as an OpenAPI 3.1 file for code generation and API clients: [grona-api.yaml](/openapi/grona-api.yaml). The API has no endpoint for campaign results. You read results in the Grona dashboard. ## A quick tour [Section titled “A quick tour”](#a-quick-tour) Three calls take a campaign from nothing to live: check the snippet, create the campaign, then set it live once you have edited and previewed it. The quickstart runs each call with curl and shows its response. [Quickstart: Public API](/get-started/quickstart-api/)Check the snippet, create a campaign, open the editor and set it live. ## Next steps [Section titled “Next steps”](#next-steps) [Create campaigns from your platform](/api/create-campaigns/)The end-to-end flow, from snippet check to live campaign. [Authentication](/api/authentication/)Get a secret key, send it and keep it safe. [Conventions](/api/conventions/)External ids, timestamps, pagination and safe retries. [Errors](/api/errors/)Every error code, and which requests are safe to retry.
# Authentication
> Authenticate server-to-server calls to the Grona Public API with a workspace secret key, and keep that key off browsers and apps.
Every call to the Public API carries a workspace secret key. This page covers how to get a key, how to send it, what it can reach and what to do when a call is refused. Read it before you write your first request. ## Get a key [Section titled “Get a key”](#get-a-key) Grona issues secret keys for your workspace. There is no page in the Grona dashboard for creating, rotating or revoking keys yourself. To get a key, or to rotate or revoke one, email . A key looks like this:
```text
grona_sk__
```
Treat the whole string as one opaque value. Do not split it or build it from parts. Grona stores only a hash of the secret, so nobody at Grona can read your key back to you. If you lose it, ask for a new one. v1 has no test mode and no separate test keys. A key acts on the real campaigns of its workspace. New campaigns start `paused`, so you can create, edit and preview a campaign without showing it to visitors until you set it live. ## Send the key [Section titled “Send the key”](#send-the-key) Send the key in one of two headers. Both work on every `/v1` endpoint that takes a key, so pick one and use it everywhere. * curl
```sh
# Authorization header
curl https://api.grona.ai/v1/campaigns \
-H "Authorization: Bearer $GRONA_SECRET_KEY"
# X-API-Key header
curl https://api.grona.ai/v1/campaigns \
-H "X-API-Key: $GRONA_SECRET_KEY"
```
* Node
```js
// list.mjs: run with `node list.mjs` (Node 18 or later).
const res = await fetch('https://api.grona.ai/v1/campaigns', {
headers: { Authorization: `Bearer ${process.env.GRONA_SECRET_KEY}` },
});
const { data, meta } = await res.json();
console.log(`${meta.count} campaigns in workspace ${meta.workspaceId}`);
```
## The workspace comes from the key [Section titled “The workspace comes from the key”](#the-workspace-comes-from-the-key) Grona resolves the workspace from the key on every call. The workspace is never part of a URL or a request body, and `meta.workspaceId` in every successful response tells you which workspace answered. This also means a key can only read and change its own workspace. Ids are looked up inside the key’s workspace, so a campaign or variation id from another workspace returns `404 not_found`. ## Keep the key on your servers [Section titled “Keep the key on your servers”](#keep-the-key-on-your-servers) The secret key can create, change and delete every campaign in your workspace. Anyone who has it can do the same. * Store it in an environment variable or your secret manager. The samples on these pages read it from `GRONA_SECRET_KEY`. * Call the API from your backend only. Never put the key in browser JavaScript, a mobile app, a public repository or a support ticket. * Keep it out of logs, error reports and analytics. Redact the `Authorization` and `X-API-Key` headers if your HTTP client logs requests. * Give it only to the services that call Grona. Caution If a key may have leaked, email to revoke it straight away, then update every server that used it with the replacement. ## Rotate or revoke a key [Section titled “Rotate or revoke a key”](#rotate-or-revoke-a-key) Rotation and revocation go through Grona support. Email and say which key you want replaced or revoked. Rotate when someone who had access leaves, when a key may have been exposed, or on your own schedule. After the change, update every server that calls Grona. ## When authentication fails [Section titled “When authentication fails”](#when-authentication-fails) A missing, malformed, unknown or revoked key returns `401` with the usual error envelope:
```json
{
"error": {
"code": "unauthorized",
"message": "Missing or invalid API key."
}
}
```
To fix it, check that: * the header is spelled `Authorization: Bearer ` or `X-API-Key: `, with no quotes or extra spaces around the key; * the full key is present, starting with `grona_sk_`, and the environment variable is set in the process that makes the call; * the key has not been rotated or revoked. Do not retry a `401` in a loop. It will not succeed until the key changes. ## Endpoints that do not use your key [Section titled “Endpoints that do not use your key”](#endpoints-that-do-not-use-your-key) Two kinds of URL work without your secret key, by design. * **Editor links.** An `editUrl` such as `https://api.grona.ai/v1/editor/launch?token=...` is opened in a browser. The short-lived token inside it is what lets the editor open, so treat the link as a secret. See [Visual editor links](/api/visual-editor-links/). * **Mobile SDK endpoints**. The Android and iOS SDKs fetch their signed config, send events and pair preview devices without a secret key. The app carries only the public workspace key. Your backend still uses the secret key for the [mobile grants API](/api/mobile-grants/). ## Next steps [Section titled “Next steps”](#next-steps) * [Errors](/api/errors/): every error code and how to fix it. * [Conventions](/api/conventions/): ids, timestamps and safe retries.
# Conventions
> Identifiers, external ids, timestamps, field presence, pagination, ordering and safe retries in the Grona Public API.
These conventions apply to every endpoint of the Public API v1. Read this page once before you build an integration: it covers how ids work, what an absent or `null` field means, and how to retry a request without creating a duplicate. ## Requests [Section titled “Requests”](#requests) * Send requests over HTTPS to `https://api.grona.ai`. Every path starts with `/v1`. * Send request bodies as JSON with `Content-Type: application/json`. * Authenticate every call with your workspace secret key. See [Authentication](/api/authentication/). ## Identifiers [Section titled “Identifiers”](#identifiers) Grona ids for campaigns, variations and workspaces are strings. Treat them as opaque: store them as they are and do not parse them or depend on their length or format. Wherever a path takes a `campaignId`, you can pass either Grona’s campaign id or your own `externalId`. Grona checks both at once, so you do not have to say which one you sent. If a value happens to match both, Grona’s id wins. ## External ids [Section titled “External ids”](#external-ids) An `externalId` is your own id for a campaign, such as the id of the record in your product that created it. Set it on create or on update. * It is optional. A campaign without one reads back as `"externalId": null`. * It is 1 to 128 characters from letters, digits, `_`, `.`, `:` and `-`. A UUID, an ObjectId or a plain number all work. * It is unique within the workspace. Sending one that another campaign already uses returns `409 conflict`, so a lookup by `externalId` always finds exactly one campaign. * Sending a campaign’s own current `externalId` again is fine. * Sending `null` on update removes it and frees it for another campaign. Deleting a campaign frees its `externalId` too. * It comes back on every campaign in every response.
```sh
curl https://api.grona.ai/v1/campaigns/CMP-1024 \
-H "Authorization: Bearer $GRONA_SECRET_KEY"
```
## Timestamps [Section titled “Timestamps”](#timestamps) Timestamps are ISO 8601 strings in UTC with millisecond precision, such as `2026-10-20T09:30:12.418Z`. Compare them as instants, not as strings, and use your server clock, not the shopper’s. ## Absent fields and `null` [Section titled “Absent fields and null”](#absent-fields-and-null) Some fields appear only on the campaigns they apply to, and some are present but `null`. | Field | When it is absent | When it is `null` | | ---------------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | `url`, `slug`, `condition`, `list` | On every `specific` campaign. They describe the multi-page rule. | Never. | | `trafficSplit` | On every `general` campaign. | Never. | | `externalId` | Never. | When you have not set one. | | `segmentId`, `segmentValue` | Never. | When the campaign has no page variable, so every visitor on its pages is eligible. | | `script.checkedAt` | Never. | Until the first check of the website has completed. | | `variations[].name`, `variations[].previewUrl` | Never. | Only when the campaign refers to a variation that no longer exists. | | `variations[].editUrl` | On list, get and update. Only create returns editor links. | Never. | ## Unknown fields and values [Section titled “Unknown fields and values”](#unknown-fields-and-values) v1 can gain fields, endpoints, optional parameters and enum values without a new version. Write clients that ignore fields they do not recognise and that handle an unknown value of `status`, `script.status`, `type` or `error.code` without failing. See [Versioning and deprecation](/api/versioning/). ## Traffic split keys [Section titled “Traffic split keys”](#traffic-split-keys) An A/B test’s `trafficSplit` uses two kinds of key, for one reason: the variation ids do not exist until the campaign does. * **On create**, key it by role: `{ "Control": 60, "Test": 40 }`. * **On update and in every response**, it is keyed by variation id, with every variation present: `{ "671a3d52e4b0a1f2c3d40022": 60, "671a3d52e4b0a1f2c3d40023": 40 }`. The values are whole numbers that total 100. Leave `trafficSplit` out on create to split 50 and 50. A `general` campaign has no split, and sending one returns `400`. ## Variation types [Section titled “Variation types”](#variation-types) Each variation has a `type` of `control` or `variant`. Grona works it out from the variation’s name: `control` when the name contains the word “control” in any letter case, `variant` otherwise. An A/B test’s two variations are named Control and Test, so they read `control` and `variant`, in that order. A general campaign’s one variation is named after the campaign, so avoid the word “control” in a general campaign’s name if you read `type`. A variation whose `name` is `null` reads `variant`. ## Lists and pagination [Section titled “Lists and pagination”](#lists-and-pagination) List endpoints return every item in one response. v1 has no pagination, and `meta.count` holds the number of items returned. The field is reserved so pagination can be added later without breaking your code. The order of a list is not specified. If you need an order, sort on your side, for example by `createdAt`. ## Rate limits [Section titled “Rate limits”](#rate-limits) Grona does not publish fixed rate limits. If you send too many requests you get `429`, and during a short outage you can get `503`. Retry both with exponential backoff and jitter and honour `Retry-After`, as described in [Errors](/api/errors/#retry-temporary-failures). ## Safe retries and idempotency [Section titled “Safe retries and idempotency”](#safe-retries-and-idempotency) The API has no `Idempotency-Key` header. Use `externalId` as the idempotency key for creates instead: because it is unique per workspace, a create that is sent twice cannot make two campaigns. 1. Send the create with an `externalId`. 2. If the response is lost or times out, send the same create again. 3. If the retry returns `409 conflict`, the first attempt succeeded. Fetch the campaign with `GET /v1/campaigns/{externalId}`. 4. Get and list do not return editor links, so request a fresh link for each variation with `POST /v1/variations/edit-link`.
```js
// create-once.mjs: create a campaign at most once (Node 18 or later).
const api = (path, init = {}) =>
fetch(`https://api.grona.ai${path}`, {
...init,
headers: {
Authorization: `Bearer ${process.env.GRONA_SECRET_KEY}`,
'Content-Type': 'application/json',
},
});
export async function createOnce(campaign) {
const res = await api('/v1/campaigns', { method: 'POST', body: JSON.stringify(campaign) });
if (res.status === 201) return (await res.json()).data;
const body = await res.json().catch(() => null);
if (res.status !== 409 || body?.error?.code !== 'conflict') {
throw new Error(`${res.status} ${body?.error?.code}: ${body?.error?.message}`);
}
// An earlier attempt created it. Read it back and mint new editor links.
const existing = (await (await api(`/v1/campaigns/${campaign.externalId}`)).json()).data;
for (const variation of existing.variations) {
const link = await api('/v1/variations/edit-link', {
method: 'POST',
body: JSON.stringify({ variationId: variation.id }),
});
Object.assign(variation, (await link.json()).data);
}
return existing;
}
```
Caution A `409` means the `externalId` is taken, not that the existing campaign matches your request. If your ids can be reused for different campaigns, compare the fetched campaign with the one you meant to create before you treat the retry as done. ### Retry updates and deletes by Grona’s id [Section titled “Retry updates and deletes by Grona’s id”](#retry-updates-and-deletes-by-gronas-id) When you retry a `PATCH` or a `DELETE`, address the campaign by the Grona `id` returned earlier, not by `externalId`. Deleting a campaign frees its `externalId` for reuse, and an update can change or clear it, so another campaign can take the same `externalId` between your first attempt and the retry. A retry by `externalId` would then change or delete that other campaign, and deletion is permanent. A delete retried by Grona’s `id` returns `404` if the first attempt succeeded; treat that as done.
# Create campaigns from your platform
> An end-to-end flow: check the snippet, create a campaign, author it in the editor, preview it, set it live and read the results.
This guide takes one campaign from your product to live visitors on your website, using the Public API from your backend. Follow it when you want users of your own platform to create Grona campaigns without opening the Grona dashboard. The example is Acme Market’s “Free delivery bar on the home page” campaign; the options at the end show an A/B test across several pages. ## Before you start [Section titled “Before you start”](#before-you-start) * You need a workspace secret key in the `GRONA_SECRET_KEY` environment variable of the server that calls Grona. See [Authentication](/api/authentication/). * The Grona snippet must be installed on your website. See [Install the Grona script](/web/install/add-the-script/). * The Node samples need Node 18 or later and use this small helper. Save it next to the other files as `grona.mjs`:
```js
// grona.mjs: a small client for the Grona Public API (Node 18 or later).
export async function grona(method, path, body) {
const res = await fetch(`https://api.grona.ai${path}`, {
method,
headers: {
Authorization: `Bearer ${process.env.GRONA_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json().catch(() => null);
if (!res.ok) {
throw new Error(`Grona ${res.status} ${json?.error?.code ?? ''}: ${json?.error?.message ?? ''}`);
}
return json;
}
```
## Create and launch a campaign [Section titled “Create and launch a campaign”](#create-and-launch-a-campaign) 1. **Check that the snippet is on the page.** Campaigns reach visitors through the Grona snippet, so confirm it is on the page the campaign will run on before you create anything. * curl
```sh
curl https://api.grona.ai/v1/websites/verify-script \
-H "Authorization: Bearer $GRONA_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://shop.example.com/"}'
```
* Node check.mjs
```js
import { grona } from './grona.mjs';
const { data } = await grona('POST', '/v1/websites/verify-script', {
url: 'https://shop.example.com/',
});
if (data.script.status !== 'present') {
throw new Error(`Grona snippet not found on ${data.url}`);
}
```
`present` means the snippet was found. `absent` means it was not found or the page could not be loaded. The check covers only the page you send. If you get `absent` on a page where the snippet is installed, see [Verify installations](/api/verify-installations/). 2. **Create the campaign.** Send a name and the page. `externalId` stores the id of the record in your own product and makes retries safe. This campaign shows to every visitor on the page; [Campaign options](#campaign-options) covers A/B tests, several pages and page variables. Everything the browser needs to run a campaign is in your site’s public script, where anyone can read it: the campaign `name`, the pages it runs on and any page variable. Do not put customer names or other private data in them. * curl
```sh
curl https://api.grona.ai/v1/campaigns \
-H "Authorization: Bearer $GRONA_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Free delivery bar on the home page",
"url": "https://shop.example.com/",
"externalId": "CMP-1024"
}'
```
* Node create.mjs
```js
import { grona } from './grona.mjs';
const { data: campaign } = await grona('POST', '/v1/campaigns', {
name: 'Free delivery bar on the home page',
url: 'https://shop.example.com/',
externalId: 'CMP-1024',
});
// Save these ids in your own database. Send each editUrl to the user's browser now.
console.log(campaign.id, campaign.status, campaign.variations.map((v) => v.id));
```
The response is `201 Created`:
```json
{
"data": {
"id": "671a3c90e4b0a1f2c3d40011",
"externalId": "CMP-1024",
"name": "Free delivery bar on the home page",
"status": "paused",
"domain": "https://shop.example.com",
"script": { "status": "checking", "checkedAt": null },
"createdAt": "2026-10-20T09:30:12.418Z",
"updatedAt": "2026-10-20T09:30:12.702Z",
"type": "general",
"pageTargeting": "specific",
"segmentId": null,
"segmentValue": null,
"variations": [
{
"id": "671a3c90e4b0a1f2c3d40012",
"name": "Free delivery bar on the home page - Variation",
"type": "variant",
"previewUrl": "https://shop.example.com/?gronaPreview=671a3c90e4b0a1f2c3d40012",
"editUrl": "https://api.grona.ai/v1/editor/launch?token=eyJhbGciOi...",
"generatedAt": "2026-10-20T09:30:12.731Z",
"expiresAt": "2026-10-20T09:45:12.731Z"
}
]
},
"meta": { "workspaceId": "65e8a1c47d2f9b03e6a15c42" }
}
```
* The campaign starts `paused`. Nothing reaches visitors until step 5. * Save `data.id` and every `variations[].id`. You need the variation ids for editor links and, in an A/B test, for the traffic split. * `script.status` is `checking` because Grona checks the campaign’s website in the background after the call returns. * Create is the only call that returns editor links for every variation, and each one expires 15 minutes after `generatedAt`. 3. **Open the editor links.** Each variation has an `editUrl` that opens it in the Grona visual editor. Send it to your user’s browser, for example as a redirect from your “Edit” button. The user makes their changes in the editor and saves them. A general campaign has one link. An A/B test has two, one for Control and one for Test, and authoring the test means opening each of them. If the link has expired, request a new one with the variation id: * curl
```sh
curl https://api.grona.ai/v1/variations/edit-link \
-H "Authorization: Bearer $GRONA_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"variationId": "671a3c90e4b0a1f2c3d40012"}'
```
* Node edit-link.mjs
```js
import { grona } from './grona.mjs';
const { data: link } = await grona('POST', '/v1/variations/edit-link', {
variationId: '671a3c90e4b0a1f2c3d40012',
});
// Redirect the user's browser to link.editUrl. Do not store or log it.
```
Caution The token in an editor link is in the query string, and anyone who holds a live link can open the editor for that variation. Open links straight away, and never store, log or forward them. See [Visual editor links](/api/visual-editor-links/). 4. **Preview the variation.** Open the variation’s `previewUrl` to see the page with the saved changes applied. It is the campaign’s page with `gronaPreview` added, using `&` instead of `?` when the URL already has a query string:
```text
https://shop.example.com/?gronaPreview=671a3c90e4b0a1f2c3d40012
https://shop.example.com/search?q=milk&gronaPreview=671a3c90e4b0a1f2c3d40012
```
Preview works while the campaign is paused. It ignores the campaign’s targeting and consent settings, and it records no page views or conversions. The parameter must stay in the URL: preview does not follow the visitor to the next page. A preview link does not expire and is not private, so anyone who has it can see the variation. See [Preview and QA a variation](/web/guides/preview-and-qa/). Grona reads `gronaPreview` from the query string only, so give a campaign a `url` without a `#fragment`. 5. **Set the campaign live.** When the variation is ready, set the status to `live`: * curl
```sh
curl -X PATCH https://api.grona.ai/v1/campaigns/CMP-1024/status \
-H "Authorization: Bearer $GRONA_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"status": "live"}'
```
* Node go-live.mjs
```js
import { grona } from './grona.mjs';
const { data } = await grona('PATCH', '/v1/campaigns/CMP-1024/status', { status: 'live' });
console.log(data.status); // "live"
```
The response is `{ "data": { "status": "live" }, "meta": { ... } }`. Grona then rebuilds your site script, and visitors see the campaign once the refreshed script reaches them, so the change is not instant. Pausing works the same way with `"paused"`, and has the same delay. 6. **Track conversions and read the results.** Grona measures the campaign against the conversion goals set up for your workspace. To count a conversion that happens in your own code, such as a completed order, call `grona('track', ...)` on the page. See [Track conversions and custom events](/web/guides/track-conversions/). The Public API has no endpoint for results. Read them in the Grona dashboard. ## Campaign options [Section titled “Campaign options”](#campaign-options) The create call accepts three options beyond a name and a page. You can combine them. ### Run an A/B test [Section titled “Run an A/B test”](#run-an-ab-test) Set `type` to `abTest` and Grona creates two variations, Control and Test, in that order. `trafficSplit` sets the share of eligible visitors each one gets, as whole numbers that total 100. Leave it out to split 50 and 50. On create, the split is keyed by `Control` and `Test`, because the variation ids do not exist yet. Every response, and every update, keys it by variation id instead. [Conventions](/api/conventions/#traffic-split-keys) has both shapes. ### Run on several pages [Section titled “Run on several pages”](#run-on-several-pages) Set `pageTargeting` to `multiple` to run one campaign on a set of pages instead of one page. Four fields describe the set: | Field | Meaning | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `url` | A sample page of the kind you want to cover. The editor opens this page. Grona stores it without its query string or fragment. | | `slug` | One segment of the sample page’s path, spelled exactly as it appears there. What counts is its position in the path. Required. | | `condition` | `included` (the default) matches pages whose segment at that position is in `list`. `not_included` matches pages whose segment at that position is not in `list`. | | `list` | The segments to compare against, 1 to 50 of them. Required. | On each page, Grona takes the path segment that sits at the same position as `slug` in the sample `url`, and compares it with each entry of `list`. The comparison is exact and case-sensitive. A page that has no segment at that position never matches, whichever `condition` you use. This request creates an A/B test on every dairy and bakery page, with 60% of eligible visitors in Control. It has no page variable, so every visitor on those pages is eligible:
```json
{
"name": "Breakfast banner on dairy and bakery pages",
"url": "https://shop.example.com/dairy/whole-milk",
"type": "abTest",
"pageTargeting": "multiple",
"slug": "dairy",
"condition": "included",
"list": ["dairy", "bakery"],
"trafficSplit": { "Control": 60, "Test": 40 }
}
```
`dairy` is the first segment of the sample path, so Grona compares the first segment of each page: * `https://shop.example.com/bakery/sourdough` matches: `bakery` is in the list. * `https://shop.example.com/frozen/peas` does not match: `frozen` is not in the list. * `https://shop.example.com/Dairy/whole-milk` does not match: `Dairy` is not `dairy`. * `https://shop.example.com/` never matches: it has no first segment. Sending `slug`, `condition` or `list` without `multiple` returns `400`. Caution `not_included` can match far more pages than you intend. With the rule above set to `not_included`, every page whose first segment is not `dairy` or `bakery` matches, including `https://shop.example.com/checkout` and your account pages. Before you set such a campaign live, test it with preview: add `gronaPreview` to the URLs of several pages the rule matches, and check how the variation looks on each. Preview ignores targeting, so it shows how a page looks with the variation, not whether the rule matches it. A campaign’s `url`, `type` and `pageTargeting` are fixed once it is created. You can change `slug`, `condition` and `list` later with an update; a new `list` replaces the old one. ### Target by page variable [Section titled “Target by page variable”](#target-by-page-variable) `segmentId` and `segmentValue` limit a campaign to visitors whose page sets a variable to a given value. `segmentId` is the variable’s name and `segmentValue` is the value it must hold. Send both or neither. With neither, every visitor on the targeted pages is eligible. Your page sets the variable on `window`:
```html
```
The create request names the variable and the value:
```json
{
"name": "Free delivery bar for returning buyers",
"url": "https://shop.example.com/",
"segmentId": "audience_id",
"segmentValue": "returning_buyer"
}
```
* **Name:** letters, digits and `_`, not starting with a digit, at most 128 characters. Names the browser already defines, such as `name`, `location`, `status`, `top`, `parent` and `history`, are rejected. * **Value:** 1 to 128 letters, digits, `_`, `.`, `:` or `-`. * **Match:** the variable must equal the value exactly, as a string. [Target visitors by page variable](/web/guides/target-by-segment/) covers how to set the variable on your site, and [Page variables](/web/configure/segments-and-page-variables/) is the reference for the fields. ## After launch [Section titled “After launch”](#after-launch) * **Change a campaign** with `PATCH /v1/campaigns/{campaignId}`. Send only the fields to change; the response is the whole campaign. See [Update a campaign](/api/reference/operations/update-campaign/). * **Read campaigns** with `GET /v1/campaigns` or `GET /v1/campaigns/{campaignId}`, using Grona’s id or your `externalId`. * **Delete a campaign** with `DELETE /v1/campaigns/{campaignId}`. Deletion is permanent and frees the `externalId`. Like a status change, it reaches visitors once your site script refreshes. If you retry an update or a delete, use Grona’s `id`, not your `externalId`; see [Conventions](/api/conventions/#retry-updates-and-deletes-by-gronas-id). ## Next steps [Section titled “Next steps”](#next-steps) * [Visual editor links](/api/visual-editor-links/): keep editor links fresh and handle them safely. * [Verify installations](/api/verify-installations/): what the snippet check covers and why it can read `absent`. * [Errors](/api/errors/): every error code and which requests are safe to retry.
# Errors
> Every error code the Grona Public API returns, what it means, how to fix it and which errors are safe to retry.
When a call fails, the Public API returns an HTTP status and, for most failures, an error body with a stable code. This page lists every code, the usual causes and the fix, and explains how to retry the failures that are temporary. ## The error body [Section titled “The error body”](#the-error-body) Apart from `429` and `503`, described below, errors use one shape on every Public API endpoint:
```json
{
"error": {
"code": "invalid_request",
"message": "`url` cannot be changed; create a new campaign for a different page."
}
}
```
* `code` is stable and machine-readable. Branch on it. * `message` is for people. Its wording can change, so log it but do not parse it. * Treat a `code` you do not recognise by its HTTP status. New codes can be added within v1. A request that fails with `400` changes nothing, even when only one of its fields was at fault. ## Error codes [Section titled “Error codes”](#error-codes) | HTTP | `error.code` | Meaning | How to fix | | ----- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | `invalid_request` | The request is malformed or breaks a rule, for example a missing field, a bad format or a field that does not apply to this campaign. | Read `message`, correct the request and send it again. See the common causes below. | | `401` | `unauthorized` | The secret key is missing, malformed, unknown or revoked. | Check the header and the key. See [Authentication](/api/authentication/#when-authentication-fails). Do not retry until the key changes. | | `404` | `not_found` | No campaign or variation with this id exists in the key’s workspace. | Check the id, or the `externalId` you looked up. An id from another workspace also returns `404`. | | `409` | `conflict` | The `externalId` is already used by another campaign in the workspace. | Use a different `externalId`, or, if you were retrying a create, fetch the existing campaign. See [Conventions](/api/conventions/#safe-retries-and-idempotency). | | `429` | none guaranteed | Too many requests. | Retry with exponential backoff and jitter, and honour `Retry-After`. | | `500` | `internal_error` | Something failed on Grona’s side. | Retry with backoff. If it persists, contact support. | | `503` | none guaranteed | Grona is temporarily unavailable. | Retry with exponential backoff and jitter, and honour `Retry-After`. | A `429` or `503` body is not guaranteed to use the error envelope, so decide on the status code alone. ## Common causes of `400` [Section titled “Common causes of 400”](#common-causes-of-400) | Cause | Fix | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | | `name` or `url` missing on create. | Send both. | | `segmentId` is not a valid name: it starts with a digit, has characters other than letters, digits and `_`, is longer than 128 characters, or is a name the browser already defines such as `name`, `location`, `status`, `top`, `parent` or `history`. | Rename the page variable, for example to `audience_id`. | | `segmentValue` has characters other than letters, digits, `_`, `.`, `:` and `-`, or is longer than 128 characters. | Use a value such as `returning_buyer`. | | Only one of `segmentId` and `segmentValue` sent on create, or on an update to a campaign that has neither. | Send both, or neither. | | `trafficSplit` sent for a `general` campaign. | Leave it out, or create the campaign with `type: "abTest"`. | | `trafficSplit` values do not total 100, or an update leaves out one of the variations. | Send whole numbers for every variation that total 100. | | `slug`, `condition` or `list` sent without `pageTargeting: "multiple"`, or `multiple` sent without `slug` or `list`. | Send the three together with `multiple`, or leave them all out. | | `list` is empty or has more than 50 entries. | Send 1 to 50 entries. | | Update sends `url`, `type` or `pageTargeting`. | These are fixed at creation. Create a new campaign instead. | | Update has an empty body. | Send at least one field. | | `status` is not `live` or `paused`. | Send one of the two. | | The campaign id in the path, or a `variationId`, is not a valid id. | Use an id from a Grona response or an `externalId` you set. | | The `url` sent to verify-script is not an `http` or `https` URL. | Send a full page URL. | | The `editUrl` sent to validate is not a Grona editor link. | Send the whole link exactly as Grona issued it. | | The `customerId` sent to mobile grants is missing, or is not 1 to 128 letters, digits, `.`, `_`, `:` or `-`. | Send the opaque customer id from the shopper’s session. | ## Retry temporary failures [Section titled “Retry temporary failures”](#retry-temporary-failures) `429`, `500` and `503` can succeed on a later attempt. Network timeouts can too. Retry them with exponential backoff and full jitter, and when the response has a `Retry-After` header, wait at least that long. It holds either a number of seconds or an HTTP date. Grona publishes no fixed rate limits, so do not hard-code a request rate; let `429` responses slow you down. Not every request is safe to repeat blindly: | Request | Safe to retry? | | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` list and get | Yes. | | `PATCH` update and set status | Yes, by Grona’s campaign `id`. Sending the same fields again gives the same result. Do not retry by `externalId`: an update can change or clear it, and another campaign can then take it. | | `DELETE` | Yes, by Grona’s campaign `id`. If the first attempt succeeded, the retry returns `404`; treat that as done. Never retry a delete by `externalId`: once the first attempt frees it, a retry can delete a new campaign that took it. | | `POST /v1/campaigns` | Only with an `externalId`. A retry of a create that already succeeded then returns `409`, and you fetch the campaign by its `externalId`. Without one, a retry can create a duplicate. | | `POST` edit-link, validate and verify-script | Yes. Each call returns a fresh answer. | | `POST /v1/mobile/grants` | Yes. Repeat calls for the same customer return that customer’s currently valid grants. Grona does not mint a new grant for a campaign while an unexpired one exists for that customer, so a retry cannot multiply the grants a shopper can redeem. | Do not retry `400`, `401`, `404` or `409` unchanged. They fail the same way until you change the request.
```js
// retry.mjs: a fetch wrapper for the Grona Public API (Node 18 or later).
const RETRYABLE = new Set([429, 500, 503]);
function retryAfterMs(res) {
const value = res.headers.get('retry-after');
if (!value) return 0;
const seconds = Number(value);
if (!Number.isNaN(seconds)) return seconds * 1000;
return Math.max(0, Date.parse(value) - Date.now());
}
export async function gronaFetch(path, init = {}, maxAttempts = 5) {
for (let attempt = 1; ; attempt++) {
const res = await fetch(`https://api.grona.ai${path}`, {
...init,
headers: {
Authorization: `Bearer ${process.env.GRONA_SECRET_KEY}`,
'Content-Type': 'application/json',
...init.headers,
},
});
if (!RETRYABLE.has(res.status) || attempt === maxAttempts) return res;
const backoff = Math.random() * Math.min(30_000, 500 * 2 ** attempt); // full jitter
await new Promise((r) => setTimeout(r, Math.max(backoff, retryAfterMs(res))));
}
}
```
## Contact support [Section titled “Contact support”](#contact-support) If an error persists or does not match this page, email with the time of the request in UTC, the method and path, the HTTP status and the `error.code`. Never include your secret key.
# Mobile grants API
> Fetch signed treatment grants for a shopper from your backend, hand them to your app, then verify them in your order backend.
A grant lets one identified shopper see one money-bearing offer in your app, such as 10% off this order. Grona signs each grant, the Grona SDK shows an offer only when it holds a matching grant, and your order backend checks the grant again before it applies the treatment. This page covers the one call your backend makes to fetch grants, where it fits, and how to handle failures and caching. The mobile grants API is available on request. ## Where it fits [Section titled “Where it fits”](#where-it-fits) 1. **The app identifies the shopper.** It passes an opaque customer id, such as `cus_7Hq2Lm9x`, to the SDK’s `identify`. See [Shoppers and identity](/mobile/concepts/identity/). 2. **The SDK asks the app for grants.** It does this on `identify`, then again shortly before the nearest grant expires, and at least once an hour, through the grant source your app provides. Your app passes the request to your backend, inside the shopper’s signed-in session. 3. **Your backend calls Grona.** It sends `POST /v1/mobile/grants` with your secret key and the customer id from the shopper’s session. 4. **Your backend returns the grants to the app.** The SDK verifies each one and shows an offer only when a grant matches its campaign, variant, treatment type and value. 5. **Your order backend verifies the grant at order time.** It checks the signature and claims, enforces single use and applies the treatment. See [Grants and order verification](/mobile/backend/grants-and-orders/). ## Fetch grants [Section titled “Fetch grants”](#fetch-grants) The request is server to server, authenticated with your workspace secret key like every other Public API call. The body has one field, the shopper’s customer id. * curl
```sh
curl https://api.grona.ai/v1/mobile/grants \
-H "Authorization: Bearer $GRONA_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"customerId": "cus_7Hq2Lm9x"}'
```
* Node
```js
// grants.mjs: call this from the route your app uses to fetch grants (Node 18 or later).
export async function fetchGrants(customerId) {
const res = await fetch('https://api.grona.ai/v1/mobile/grants', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.GRONA_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ customerId }),
signal: AbortSignal.timeout(5000),
});
if (!res.ok) throw new Error(`Grona grants request failed: ${res.status}`);
const { data } = await res.json();
return data.grants; // Can be empty: no grant applies right now.
}
// In your route handler, take the id from the shopper's session, never from the request.
// Return the list on success. On failure, return an error status so the app reports a
// failed fetch and the SDK retries later:
//
// try {
// res.json({ grants: await fetchGrants(session.customerId) });
// } catch {
// res.status(503).json({ error: 'grants_unavailable' });
// }
```
The response lists the shopper’s grants:
```json
{
"data": {
"grants": ["eyJhbGciOiJFUzI1NiIs..."]
},
"meta": { "workspaceId": "65e8a1c47d2f9b03e6a15c42" }
}
```
An empty `grants` list is a normal answer: no offer applies to this shopper right now. Return the list to the app as it is. ### Repeat calls [Section titled “Repeat calls”](#repeat-calls) Repeat calls for the same customer return that customer’s currently valid grants. Grona does not mint a new grant for a campaign while an unexpired one exists for that customer, so retries and refetches cannot multiply the grants a shopper can redeem. That makes the call safe to retry. Your order backend still enforces single use for each grant, keyed on its `jti` claim. Once a grant expires, a later call can return a new grant for the same campaign, with a new `jti`. If an offer must apply once per customer rather than once per grant, your order backend enforces that too. Caution Take `customerId` from the shopper’s authenticated session on your backend. If your route accepted a customer id from the app, anyone could fetch grants for another shopper. ## The customer id [Section titled “The customer id”](#the-customer-id) `customerId` is the same opaque id your app passes to the SDK’s `identify`. It becomes the grant’s `sub` claim, and both the SDK and your order backend check it against the shopper. Never use an email address, a phone number or a national id. ## The grant format [Section titled “The grant format”](#the-grant-format) Each grant is a compact JWS signed by Grona, at most 4 KB, that names one shopper, one campaign and variant, and one treatment. It expires at most 24 hours after it is issued. [Offers and grants](/mobile/concepts/offers-and-grants/) explains the header and every claim. ## Errors [Section titled “Errors”](#errors) | Status | When | What to do | | --------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | `400 invalid_request` | `customerId` is missing, or is not 1 to 128 letters, digits, `.`, `_`, `:` or `-`. | Send the customer id from the shopper’s session. The SDK’s `identify` accepts the same characters. | | `401 unauthorized` | The secret key is missing, malformed, unknown or revoked. | See [Authentication](/api/authentication/). | | `429`, `503` | Too many requests, or Grona is briefly unavailable. | Report a failed fetch to the app. The SDK retries with backoff. | | `500 internal_error` | Something failed on Grona’s side. | Report a failed fetch to the app. The SDK retries with backoff. | When the call to Grona fails or times out, tell the app the fetch failed rather than returning an empty list. The app’s grant source then reports a failure (on Android, `fetchGrants` returns `null`), and the SDK retries with backoff. An empty list means the fetch succeeded and no grant applies, so the SDK does not retry straight away. Either way the shopper sees no offer until a grant arrives: the campaign gives way to the next one in that placement and the app keeps working. The SDK waits at most 10 seconds for a grant fetch, so keep your route fast. ## Caching [Section titled “Caching”](#caching) Grants are short-lived: each one expires at most 24 hours after it is issued. The SDK keeps them in memory only, and refetches shortly before the nearest one expires and at least once an hour. * You do not need to cache grants. Fetching them on each request from the app is the expected pattern: repeat calls return the customer’s currently valid grants, not new ones. * If you do cache, key the cache by customer id, never share entries between shoppers, and drop an entry no later than the earliest `exp` among its grants. * Clear any cached grants when the shopper signs out. ## What Grona does not provide [Section titled “What Grona does not provide”](#what-grona-does-not-provide) * **No redeem endpoint.** Your order backend verifies each grant itself and enforces single use, keyed on the grant’s `jti` claim and never on the token string. See [Grants and order verification](/mobile/backend/grants-and-orders/). * **No key-download endpoint.** There is no API for fetching signing keys. See [Rotate signing keys](/mobile/backend/key-rotation/). ## Next steps [Section titled “Next steps”](#next-steps) * [Backend integration overview](/mobile/backend/overview/): everything your servers do for the mobile SDKs. * [Offers and grants](/mobile/concepts/offers-and-grants/): how offers, grants and treatments fit together.
# Verify installations
> Check from your backend whether the Grona snippet is on a page, what the check covers and why it can read absent on a working install.
The verify-script endpoint loads one of your pages and reports whether your workspace’s Grona snippet is on it. Call it from your backend before you set a campaign live, or in your own onboarding flow to tell a user their install is working. This page covers the request, what the check looks for, how Grona matches a URL to your website, and the cases where a working install reads `absent`. ## Check a page [Section titled “Check a page”](#check-a-page) * curl
```sh
curl https://api.grona.ai/v1/websites/verify-script \
-H "Authorization: Bearer $GRONA_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://shop.example.com/"}'
```
* Node
```js
// verify.mjs (Node 18 or later)
const res = await fetch('https://api.grona.ai/v1/websites/verify-script', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.GRONA_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ url: 'https://shop.example.com/' }),
});
const { data } = await res.json();
console.log(data.url, data.script.status, data.script.checkedAt);
```
The call waits for the check to finish, then returns the page it checked and the result:
```json
{
"data": {
"url": "https://shop.example.com/",
"script": { "status": "present", "checkedAt": "2026-10-20T09:28:43.162Z" }
},
"meta": { "workspaceId": "65e8a1c47d2f9b03e6a15c42" }
}
```
`url` must be a full `http` or `https` page URL. Anything else returns `400 invalid_request`. ## What the check looks for [Section titled “What the check looks for”](#what-the-check-looks-for) Grona loads the page in a headless browser and waits for it to finish loading. It then looks in the page’s ``, and after that in its ``, for a `
```
The first line opens the connection to Grona’s CDN early. The second hides the page until the variation is applied, so visitors normally do not see the original content change in front of them. The third is the runtime script that decides and applies variations. 2. **Paste it at the top of ``, above every other script, on every page.** Put it in your base template or layout so every page carries it. Keep the three lines in this order, and do not add `async` or `defer` to `antiflicker.js`. Set `data-grona-af-bg` to your page’s background colour. It is the colour visitors see while the page is hidden. 3. **Deploy the change.** If you use a cache, optimisation or “delay JavaScript” plugin, exclude both `cdn.grona.ai` scripts from it, so it cannot defer, bundle or rename them. Caution Install the snippet in the page source, not through Google Tag Manager or another asynchronous loader. A tag manager injects the scripts after the page has started to render, so the anti-flicker script cannot hide the first paint and visitors may see the original content before the variation. See [Google Tag Manager](/web/platforms/google-tag-manager/) if you have no other option. For platform-specific steps, see the [platform guides](/web/#platform-guides) or [Install the Grona script](/web/install/add-the-script/). ## Verify the installation [Section titled “Verify the installation”](#verify-the-installation) 1. Open your site in a browser and open the developer tools on the **Network** tab. 2. Reload the page and filter by `grona`. You should see `antiflicker.js` and `main.js` from `cdn.grona.ai`, each with status `200` or `304`. 3. In the Grona dashboard, run the installation check for the page. It loads the page in a headless browser and looks for your workspace’s `main.js`. If it reports that the script was not found, check that no plugin added a query string such as `?ver=` to the script URL and that no consent tool holds the script back. The check looks at one URL only and does not test the anti-flicker script, the order of the tags or whether the script ran. See [Verify your installation](/web/install/verify/) for the full list of checks. ## Create and preview a variation [Section titled “Create and preview a variation”](#create-and-preview-a-variation) 1. **Create a campaign in the dashboard** for a page on your site, for example `https://shop.example.com/`. For this first test, use a campaign with a single variation and no control split, so every visitor who matches sees the variation and you will not land in control. 2. **Build the variation in the visual editor.** For example, add a free-delivery progress banner above the product grid. The editor opens your real page, so the page must be reachable from the public internet without a login. See [Visual editor requirements](/web/configure/visual-editor-requirements/). 3. **Open the preview link.** It is your page URL with `?gronaPreview=` and the variation id. Preview applies the variation on your live page while the campaign is still paused. It ignores targeting and consent and records nothing, so you can reload it as often as you need. Preview links do not expire, and anyone with the link can open it. Do not treat one as private. See [Preview and QA a variation](/web/guides/preview-and-qa/). ## Set it live and confirm [Section titled “Set it live and confirm”](#set-it-live-and-confirm) 1. **Set the campaign live** in the dashboard. Grona rebuilds your workspace’s script and refreshes the CDN. This is not instant, so allow a few minutes. 2. **Open the page in a private browser window,** without the preview parameter. 3. **Look for the variation marker in the address bar.** When a variation other than control is applied, the script adds `v=grona-` and the first eight characters of the variation id to the URL:
```text
https://shop.example.com/?v=grona-3f9a12bc
```
If the variation does not appear and there is no marker: * **The new script has not reached you yet.** Wait a few minutes and reload. * **The page does not match the campaign’s URL rules.** Paths are case-sensitive, and `http://` does not match `https://`. See [Target pages with URL rules](/web/guides/target-pages/). * **Your workspace waits for consent.** Give analytics consent in your consent banner, then load another page. Consent given on a page counts from the next page view. See [Consent and storage](/web/configure/consent-and-cookies/). * **You are in the control group of an A/B test.** Control shows the original page and adds no marker. Use the preview link to see the variation. See [Troubleshooting](/web/help/troubleshooting/) for other symptoms. ## Next steps [Section titled “Next steps”](#next-steps) * [Implementation checklist](/web/install/checklist/) before you run campaigns on real traffic. * [Content Security Policy](/web/configure/content-security-policy/) if your site sends a CSP header. * [Track conversions and custom events](/web/guides/track-conversions/) to measure orders and sign-ups. * [Single-page applications](/web/configure/single-page-apps/) if your site changes routes without a full page load.
# Get help
> How to reach Grona support and what to include so support can help on the first reply.
Email **** for help with an integration, a key, or anything on this site. This page lists what to put in your message so support can reproduce the problem on the first reply, and where to send security reports. ## Check these first [Section titled “Check these first”](#check-these-first) * **Website:** [Troubleshooting](/web/help/troubleshooting/), [Debug in the browser](/web/help/debug-in-the-browser/) and the [FAQ](/web/help/faq/). * **Android:** [Test and debug](/mobile/android/test-and-debug/). The debug view shows the configuration, campaigns, arms and events on the device. * **iOS:** [Test and debug](/mobile/ios/test-and-debug/). * **Public API:** [Errors](/api/errors/) lists every error code and what to do about it. ## What to include [Section titled “What to include”](#what-to-include) | Detail | Example | Why | | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | | Workspace id (web) or workspace key (mobile) | `65e8a1c47d2f9b03e6a15c42`, `acme-market` | Support looks up your workspace with it | | Where it happened | Website: the full page URL, such as `https://shop.example.com/cart`. App: the app version, the SDK version (`0.1.0`), the platform and OS version, and the device | Most problems depend on the page or the build | | Campaign and variation ids | The campaign id or your `externalId` (`CMP-1024`), and the variation id | Narrows the problem to one campaign | | When it happened | `2026-10-29 09:14 UTC` | Lets support line it up with campaign changes and events | | Steps to reproduce | Numbered steps from a fresh browser or a fresh install | So support can see it too | | What you expected and what happened | “Expected the banner above the product grid; the page showed the original” | Separates a bug from a targeting or consent setting | | Evidence | Console output, a HAR file, a screenshot, or for the API the full request without the key and the full error response | Shows what actually happened | For the website, also say whether your workspace waits for consent and whether you opened the page with a preview link. For apps, say which consent state you set and whether a preview pin was active. Danger Never send a secret API key, an editor link, a grant token or a mobile preview pairing code to support, in an email, a screenshot or a HAR file. Remove the `Authorization` header from any request you share. If a key has been exposed, email to have it revoked and replaced. ## Security reports [Section titled “Security reports”](#security-reports) Send security reports to the same address, . Describe the affected host, page, endpoint or SDK version, the steps to reproduce and what an attacker could do. Do not include real customer data. ## Keys [Section titled “Keys”](#keys) Grona issues secret API keys. To get a key, rotate one or revoke one, email . There is no self-serve key management. See [Keys and environments](/get-started/keys-and-environments/).
# Mobile SDKs overview
> What the Grona Android and iOS SDKs do, how they render, and what they need from your app.
The Grona mobile SDKs let Grona personalise screens inside your existing Android and iOS apps. You add placements to your screens once and ship one release. After that, what fills each placement, for which shoppers and against which control group is configuration in Grona, not app code. Read this page first to understand what the SDKs do and what your app has to provide. Available on request The mobile SDKs are at version 0.1.0 and available on request. Email to get access. Until 1.0, APIs and behaviour can change between minor versions. See [Versioning and support policy](/mobile/concepts/versioning-policy/). ## What the SDKs do [Section titled “What the SDKs do”](#what-the-sdks-do) The SDK runs on the device. It applies a signed campaign configuration, decides which campaign each placement shows and which arm the shopper is in, renders or hands over the result, and records events for analysis. * **Signed configuration.** Grona publishes one signed JSON configuration per workspace. The SDK applies a copy bundled with your app at launch, then refreshes it from the network. It rejects tampered, rolled-back or expired configurations and keeps the last good one. See [Signed configuration](/mobile/concepts/signed-configuration/). * **Decisions on the device.** Audience rules read the basket, the current screen, the shopper’s context and, for identified customers, a profile your app supplies. Assignment to variants and holdouts is deterministic and identical on Android, iOS and the Grona backend. See [Assignment and bucketing](/mobile/concepts/assignment/). * **Measurement for every arm.** Control and holdout shoppers are measured on the same anchor as treatment shoppers, so results compare like with like. See [Events and exposure](/mobile/concepts/events-and-exposure/). * **Money verified on the server.** An offer that carries a reward renders only with a grant signed by Grona, and your order backend verifies that grant before applying anything. See [Offers and grants](/mobile/concepts/offers-and-grants/). * **Fail safe.** Apart from rejecting invalid options when you build them, the SDK never throws into your app. Any error ends in an empty placement or a missing offer, never a crash or a blocked order. ## Two rendering modes [Section titled “Two rendering modes”](#two-rendering-modes) Each campaign renders in one of two ways. You can mix them in one app, placement by placement. * **SDK-rendered components.** You add a slot view to your layout. Grona draws a native banner, progress bar, offer or message inside it, styled with your app’s fonts and colours. See [Grona components](/mobile/concepts/components/). * **Host-rendered decisions.** You ask for a decision for a placement and draw the result with your own product cells, prices and stock. Grona tells you which products to show and with what title. Decisions are product rails, bundles, single product cards and out-of-stock substitutes. [Rendering modes](/mobile/concepts/rendering-modes/) compares the two and helps you choose. ## What the SDKs need from your app [Section titled “What the SDKs need from your app”](#what-the-sdks-need-from-your-app) The SDK never reaches into your app’s code or data on its own. Your app gives it what it needs: * **A host implementation.** Your app answers product lookups (name, price, stock, image) from memory, adds lines to the basket, replaces a basket line, and opens typed routes such as a product or category screen with your own router. The SDK calls these on the main thread only. * **State pushes.** Your app pushes the basket whenever it changes, the shopper’s context (locale, store branch and delivery type), the opaque customer id when a shopper signs in, an optional profile from your authenticated session, and the shopper’s consent. * **Commerce events.** Your app forwards product views, basket changes, checkout starts and purchases, so Grona can measure outcomes. * **Signing keys and endpoints.** Your app ships the public keys that verify configurations and grants, and the URLs for the configuration, events and preview pairing. * **A backend for offers.** If you run offers with a reward, your backend fetches grants for the signed-in shopper and verifies them again when the order is placed. See [Backend integration overview](/mobile/backend/overview/). ## Platform requirements [Section titled “Platform requirements”](#platform-requirements) | Platform | Minimum version | | -------- | ----------------------------------- | | Android | Android 7.0 (API level 24) or later | | iOS | iOS 16 or later | Both SDKs share one contract: the same configuration, the same events, the same bucketing and the same security rules. Each platform’s pages note the few differences. A shopper gets the same arm on both platforms when the campaign assigns by customer. ## Explore the section [Section titled “Explore the section”](#explore-the-section) [Concepts](/mobile/concepts/rendering-modes/)Rendering modes, placements, components, identity, events, consent and the security model, for both platforms. [Android](/mobile/android/install/)Install the SDK, initialise it, and render components and decisions on Android. [iOS](/mobile/ios/install/)Install the SDK, initialise it, and render components and decisions on iOS. [Backend integration](/mobile/backend/overview/)Fetch grants, verify them at order time, report revenue and rotate signing keys.
# Android API reference
> Every public class, method and option in the Grona Android SDK 0.1.0, with Kotlin signatures.
This page lists the public API of `ai.grona:grona-core:0.1.0`. Every type is in the package `ai.grona.sdk`. Methods on `Grona` and the companion factories are annotated `@JvmStatic`, so Java callers use them as static methods. The SDK never throws into your app, with one exception: `GronaOptions.Builder.build()` throws `IllegalArgumentException` on invalid input. ## Grona [Section titled “Grona”](#grona)
```kotlin
public object Grona
```
The SDK entry point. Call `init` once from `Application.onCreate`. Every other call does nothing until then. Setters are safe from any thread. | Member | Description | | -------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `const val VERSION: String` | The SDK version, `"0.1.0"`. Compared against the minimum SDK version in configurations. | | `fun init(context: Context, options: GronaOptions, host: GronaHost)` | Starts the SDK. Constant time: work is queued off the main thread. Ignored outside the main process and on repeat calls. | | `fun setConsent(consent: GronaConsent)` | Sets the consent state. Defaults to `UNKNOWN`: events stay in memory and none are sent. | | `fun identify(customerId: String?)` | An opaque internal customer id, never a phone number, email or national id; `null` on logout. Clears the profile and grants. | | `fun setProfile(profile: GronaProfile?)` | Profile from your own authenticated session. Ignored unless it matches the identified customer. | | `fun setContext(context: GronaContext)` | Sets the shopper context that rules read. | | `fun setBasket(basket: GronaBasket)` | Push a fresh snapshot whenever the basket changes. Live components and decisions re-evaluate. | | `fun track(event: GronaEvent)` | Records a commerce event. | | `fun decision(anchor: View, placement: String, screen: GronaScreen, listener: GronaDecisionListener): GronaSubscription` | Decision for a host-rendered placement, bound to `anchor`. Main thread only. | | `fun decision(anchor: View, placement: String, screen: GronaScreen, key: String?, listener: GronaDecisionListener): GronaSubscription` | As above, for keyed placements such as `cart_line`, where `key` is the line’s product id. | | `fun activeTreatments(): List` | Treatments currently shown to this shopper, each with the grant token your order backend must verify. | | `fun refresh()` | Polls the activation source and asks for a configuration refresh, subject to the 300-second floor. No completion signal. | | `fun refresh(listener: GronaRefreshListener)` | Refreshes now and calls `listener` once on the main thread when done, also on timeouts, failures and before `init`. | | `fun handleLink(uri: Uri): Boolean` | Handles `://grona-preview?...`. Returns `true` when the link was Grona’s, whether or not it was accepted. | | `fun reset()` | Logout or erasure: new install id, and the queue, customer, profile, grants, basket snapshot and preview pins cleared. | | `fun openDebugView(context: Context)` | Opens the debug view when the `grona-debug` artifact is present and the app is debuggable. Otherwise does nothing. | ## GronaOptions [Section titled “GronaOptions”](#gronaoptions)
```kotlin
public class GronaOptions
public class GronaOptions.Builder(workspaceKey: String)
```
Options for `Grona.init`. Build with `GronaOptions.Builder`. `build()` throws on invalid input. Each builder method returns the `Builder`. Every option is also readable as a property of the same name on `GronaOptions`, except that keys are readable as the maps `configKeys` and `grantKeys`. | Builder member | Description | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Builder(workspaceKey: String)` | The workspace key, a public identifier, not a secret. Must match `[A-Za-z0-9._:-]{1,128}`. | | `fun configUrl(url: String)` | Signed remote configuration (https). Without it the SDK runs on the bundled copy. | | `fun bundledConfigAsset(path: String)` | Asset path of the signed bundled configuration, applied at launch and offline. | | `fun configKey(kid: String, publicKeySpki: String)` | Trusted configuration signing key: base64 DER SubjectPublicKeyInfo of a P-256 key. Add current and next. At least one is required. | | `fun grantKey(kid: String, publicKeySpki: String)` | Trusted grant signing key, same format as `configKey`. | | `fun eventsUrl(url: String)` | Events endpoint (https). Without it the SDK sends no events and keeps no event queue. Debuggable builds keep the last 200 events in memory for the debug view. | | `fun previewUrl(url: String)` | Preview pairing endpoint (https). Without it preview links only clear pins. | | `fun activationSource(source: GronaActivationSource)` | Source of on and off answers from an external campaign system. | | `fun grantSource(source: GronaGrantSource)` | Fetches treatment grants from your backend. | | `fun theme(theme: GronaTheme)` | Visual tokens for SDK components. | | `fun logger(logger: GronaLogger)` | Receives SDK diagnostics. Default `GronaLogger.NONE`. | | `fun maxAddQuantity(max: Int)` | Highest quantity one action may add, 1 to 20. Default 5. | | `fun maxAddLines(max: Int)` | Most lines one action may add, 1 to 20. Default 5. | | `fun sendPreConsentEvents(enabled: Boolean)` | Send events buffered before consent once it is granted. Default `false`. | | `fun demoMode(enabled: Boolean)` | Demo only, never in production. Takes effect only when the signed configuration also enables demo mode. Default `false`. | | `fun build(): GronaOptions` | Validates and builds. Throws `IllegalArgumentException` for an invalid workspace key, a URL that is not https, no `configKey`, or add caps outside 1 to 20. | ## GronaHost [Section titled “GronaHost”](#gronahost)
```kotlin
public interface GronaHost
```
Implemented by your app. The SDK calls it only on the main thread and never reaches into app code. | Member | Description | | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | `fun product(productId: String): GronaProduct?` | Product facts for validation and copy. Must be a fast in-memory lookup; `null` when unknown. | | `fun add(lines: List, source: GronaSource): GronaAddResult` | Adds lines after the shopper tapped a Grona call to action. | | `fun replace(productId: String, with: GronaLine, source: GronaSource): GronaAddResult` | Replaces one basket line, for example an out-of-stock item with its substitute. | | `fun navigate(route: GronaRoute, source: GronaSource): Boolean` | Opens `route` with your own router; `false` when the route is unknown. | ## GronaSlotView [Section titled “GronaSlotView”](#gronaslotview)
```kotlin
public class GronaSlotView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : FrameLayout
```
A place in your layout where Grona renders native components: banner, progress, offer and message. With nothing to show it stays laid out at zero height. Never set it `GONE` or `INVISIBLE`. | Member | Description | | -------------------------- | ------------------------------------------------------------------------------------ | | `var placement: String` | The placement. Usually set in XML with `app:gronaPlacement`. Set it before `screen`. | | `var screen: GronaScreen?` | Starts the slot; rules read it. An equal value continues the visit; `null` ends it. | ## GronaScreen [Section titled “GronaScreen”](#gronascreen)
```kotlin
public class GronaScreen
```
Where the shopper is. Rules read it. | Member | Description | | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | | `val type: String` | The screen type, such as `home` or `product`. | | `val productId: String?` | The product, for product screens. | | `val categoryId: String?` | The category, for category screens. | | `val treatmentTypes: List` | For order confirmation: treatments your order backend applied. | | `val name: String?` | Your name for an `other` screen. | | `HOME`, `CATEGORIES`, `SEARCH`, `CART`, `CHECKOUT` | Fixed screens. | | `fun category(categoryId: String): GronaScreen` | A category listing. | | `fun product(productId: String): GronaScreen` | A product page. | | `fun orderConfirmation(treatmentTypes: List): GronaScreen` | Order confirmation. `treatmentTypes` are the treatments your order backend actually applied. | | `fun other(name: String): GronaScreen` | Any other screen. | ## GronaBasket [Section titled “GronaBasket”](#gronabasket)
```kotlin
public class GronaBasket
public class GronaBasket.Builder(currency: String)
```
Basket snapshot pushed with `Grona.setBasket`. Money is in minor units. | Member | Description | | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | | `Builder(currency: String)` | The currency code, such as `USD`. | | `fun line(productId: String, qty: Int, unitPriceMinor: Long, inStock: Boolean): Builder` | Adds one basket line. | | `fun totalMinor(total: Long): Builder` | The total rules compare against; defaults to the sum of the lines. | | `fun build(): GronaBasket` | Builds the snapshot. | | `GronaBasket.empty(currency: String): GronaBasket` | An empty basket. | ## GronaContext [Section titled “GronaContext”](#gronacontext)
```kotlin
public class GronaContext
public class GronaContext.Builder()
```
Shopper context that rules can read. Signed-in state comes from `Grona.identify`. | Member | Description | | ------------------------------------------ | ---------------------------------------- | | `fun locale(locale: String): Builder` | The shopper’s locale. Default `"en"`. | | `fun branchId(id: String?): Builder` | Your store or branch id. Default `null`. | | `fun deliveryType(type: String?): Builder` | Your delivery type. Default `null`. | | `fun build(): GronaContext` | Builds the context. | ## GronaProfile [Section titled “GronaProfile”](#gronaprofile)
```kotlin
public class GronaProfile
public class GronaProfile.Builder(customerId: String)
```
Per-shopper data from your authenticated session, bound to one customer id. Segment ids are opaque. | Member | Description | | ----------------------------------------------------------- | ------------------------------------ | | `Builder(customerId: String)` | The customer the profile belongs to. | | `fun segment(id: String): Builder` | Adds one segment id. | | `fun segments(ids: Collection): Builder` | Adds several segment ids. | | `fun list(name: String, productIds: List): Builder` | Sets a named list of product ids. | | `fun attribute(name: String, value: String): Builder` | Sets a string attribute. | | `fun build(): GronaProfile` | Builds the profile. | ## GronaEvent [Section titled “GronaEvent”](#gronaevent)
```kotlin
public class GronaEvent
```
Commerce events your app forwards from its analytics layer. Money is in minor units; no search text, no full baskets. Exception: on keyed placements such as `cart_line`, campaign events carry the line’s product id as their `key`, in every arm. So while a `cart_line` campaign is live, the product ids of the cart lines bound to it leave the device. | Member | Description | | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `fun viewItem(productId: String, priceMinor: Long): GronaEvent` | A product view. | | `fun addToCart(productId: String, qty: Int, priceMinor: Long): GronaEvent` | A cart add. | | `fun removeFromCart(productId: String, qty: Int): GronaEvent` | A cart removal. | | `fun beginCheckout(totalMinor: Long): GronaEvent` | Checkout started. | | `fun purchase(orderId: String, totalMinor: Long, productIds: List, treatments: List): GronaEvent` | A confirmed order. `productIds` is the whole order; the SDK keeps only those Grona campaigns showed or added. | ## GronaDecision [Section titled “GronaDecision”](#gronadecision)
```kotlin
public class GronaDecision
```
A product decision your app renders with its own views. Treat unknown types as no decision. | Member | Description | | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | `val placement: String` | The placement. | | `val campaignId: String` | The campaign. | | `val variantId: String` | The variant. | | `val type: String` | `rail`, `bundle`, `substitute` or `product_card`. | | `val title: String?`, `val subtitle: String?`, `val badge: String?`, `val ctaLabel: String?` | Copy from the configuration. Render as plain text. | | `val products: List` | The products to show. | | `val key: String?` | For keyed placements such as `cart_line`: the line this decision belongs to. | | `val extras: Map` | Layout hints from the configuration, such as `position` for feed rows. Untrusted. | | `val isPreview: Boolean` | `true` while a preview pin produced this decision. | | `val source: GronaSource` | Placement, campaign and variant. | | `fun add(productId: String): GronaAddResult` | Call from the shopper’s tap. The SDK validates, logs the action and calls `GronaHost.add`. | | `fun add(productId: String, qty: Int): GronaAddResult` | As above, with a quantity. | | `fun addAll(): GronaAddResult` | Adds every product in the decision. | | `fun replace(withProductId: String): GronaAddResult` | Replaces `key` with `withProductId` through `GronaHost.replace`. | | `fun dismiss()` | Hides this decision for the rest of the session. | | `RAIL`, `BUNDLE`, `SUBSTITUTE`, `PRODUCT_CARD` | Type constants: `"rail"`, `"bundle"`, `"substitute"`, `"product_card"`. | ### GronaDecisionProduct [Section titled “GronaDecisionProduct”](#gronadecisionproduct) | Member | Description | | ----------------------- | ------------------------------------------- | | `val productId: String` | The product. | | `val qty: Int` | The quantity `addAll` adds. | | `val label: String?` | A per-product label from the configuration. | ## Listeners and subscriptions [Section titled “Listeners and subscriptions”](#listeners-and-subscriptions) | Type | Member | Description | | ------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------- | | `fun interface GronaDecisionListener` | `fun onDecision(decision: GronaDecision?)` | Receives decisions on the main thread; `null` means show nothing. | | `fun interface GronaRefreshListener` | `fun onRefreshed()` | Completion of `Grona.refresh`; called once on the main thread. | | `fun interface GronaSubscription` | `fun cancel()` | Ends the decision’s subscription and its visit. | | | `GronaSubscription.NONE` | A subscription that does nothing, returned before `init`. | ## GronaTreatment [Section titled “GronaTreatment”](#gronatreatment)
```kotlin
public class GronaTreatment
```
A money-bearing treatment. Display state only: your order backend applies it only after verifying `token`. | Member | Description | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `val campaignId: String` | The campaign. | | `val variantId: String` | The variant. | | `val placement: String` | Where it was shown. | | `val type: String` | The treatment type. | | `val valueMilli: Long` | Value in thousandths, so `10000` is 10 (10% off with `percent_off`). | | `val token: String?` | Grona-signed grant (compact JWS); `null` in demo mode. A real backend ignores a treatment without a token: no treatment, and the order still goes through. | | `val isDemo: Boolean` | `true` for a demo-mode treatment. | ## Value types [Section titled “Value types”](#value-types) | Type | Members | Description | | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `enum class GronaConsent` | `UNKNOWN`, `GRANTED`, `DENIED` | The shopper’s consent state. | | `enum class GronaAddResult` | `ACCEPTED`, `PARTIAL`, `REJECTED` | The outcome of an add or replace. | | `class GronaLine(productId: String, qty: Int)` | `val productId: String`, `val qty: Int`; secondary constructor `GronaLine(productId: String)` with `qty` 1 | One product and quantity in an action. | | `class GronaSource` | `val placement: String`, `val campaignId: String`, `val variantId: String` | Which campaign and variant an action came from. | | `class GronaRoute` | `val type: String`, `val id: String?` | A typed destination your app resolves itself. Grona never passes raw links. | | | `HOME`, `PRODUCT`, `CATEGORY`, `CART`, `CHECKOUT`, `DEALS` | Route type constants: `"home"`, `"product"`, `"category"`, `"cart"`, `"checkout"`, `"deals"`. Unknown types may arrive from newer configurations; return `false` from `GronaHost.navigate` for them. | ## GronaProduct [Section titled “GronaProduct”](#gronaproduct)
```kotlin
public class GronaProduct
public class GronaProduct.Builder(productId: String, name: String, priceMinor: Long)
```
Product facts from your app. Money is in minor units. Each builder value is also readable as a property: `productId`, `name`, `priceMinor`, `wasPriceMinor`, `imageUri`, `inStock`. | Member | Description | | ------------------------------------------------------------ | ---------------------------------------- | | `Builder(productId: String, name: String, priceMinor: Long)` | Id, display name and price. | | `fun wasPriceMinor(price: Long?): Builder` | A previous price. Default `null`. | | `fun imageUri(uri: String?): Builder` | The product image. Default `null`. | | `fun inStock(inStock: Boolean): Builder` | Whether it can be added. Default `true`. | | `fun build(): GronaProduct` | Builds the product. | ## GronaTheme [Section titled “GronaTheme”](#gronatheme)
```kotlin
public class GronaTheme
public class GronaTheme.Builder()
```
Visual tokens so SDK components match your app. Each value is also readable as a property: `fontRegular`, `fontBold`, `primary`, `onPrimary`, `accent`, `surface`, `text`, `mutedText`, `cornerRadiusDp`. | Member | Default | | --------------------------------------------------------- | -------------- | | `fun fonts(regular: Typeface?, bold: Typeface?): Builder` | `null`, `null` | | `fun primary(color: Int): Builder` | `0xFF054B3A` | | `fun onPrimary(color: Int): Builder` | `0xFFFFFFFF` | | `fun accent(color: Int): Builder` | `0xFF6FCF97` | | `fun surface(color: Int): Builder` | `0xFFFFFFFF` | | `fun text(color: Int): Builder` | `0xFF1A1C1A` | | `fun mutedText(color: Int): Builder` | `0xFF6B706C` | | `fun cornerRadiusDp(radius: Float): Builder` | `14f` | | `fun build(): GronaTheme` | | See [Theming](/mobile/android/theming/) for what each token colours. ## Callbacks your app implements [Section titled “Callbacks your app implements”](#callbacks-your-app-implements) | Type | Member | Description | | ------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `fun interface GronaLogger` | `fun log(priority: Int, message: String)` | Receives SDK diagnostics. Silent by default; never given customer ids, baskets, tokens or keys. | | | `GronaLogger.NONE` | The silent default. | | `fun interface GronaActivationSource` | `fun activeExternalIds(): Set?` | On and off answers from an external campaign system. Called on a Grona background thread; may block up to 10 seconds. `null` when unknown. Activation can only switch campaigns off. | | `fun interface GronaGrantSource` | `fun fetchGrants(customerId: String): List?` | Fetches grants from your backend, which asks Grona’s server for them. Called on a Grona background thread; may block up to 10 seconds. Compact JWS grants, or `null` on failure. |
# Basket and context
> Push a fresh basket snapshot on every cart change and tell Grona the shopper's locale, store and delivery type.
This page keeps Grona’s view of the cart and the shopper’s situation current. Audience rules, progress bars and substitutes all read the basket, so push a fresh snapshot every time the cart changes. Context covers the shopper’s locale, store and delivery type, and changes less often. ## Push the basket [Section titled “Push the basket”](#push-the-basket) `GronaBasket` is a snapshot, not a live view of your cart. Build a new one from your cart state and pass it to `Grona.setBasket` after every change.
```kotlin
import ai.grona.sdk.Grona
import ai.grona.sdk.GronaBasket
fun pushBasket(cart: AcmeCart) {
val basket = GronaBasket.Builder("USD")
.apply {
cart.lines.forEach { line ->
line(line.productId, line.qty, line.unitPriceMinor, line.inStock)
}
}
.totalMinor(cart.totalMinor)
.build()
Grona.setBasket(basket)
}
```
With milk and spaghetti in the cart, the snapshot is equivalent to:
```kotlin
GronaBasket.Builder("USD")
.line("sku_10432", 2, 349, true) // 2 x 3.49
.line("sku_20877", 1, 199, false) // out of stock
.build() // total 897, the sum of the lines
```
| Builder method | What it sets | | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GronaBasket.Builder(currency)` | The currency code, for example `USD`. | | `line(productId, qty, unitPriceMinor, inStock)` | One cart line. `unitPriceMinor` is in minor units, so `349` is 3.49. Set `inStock` to `false` for lines that are out of stock. | | `totalMinor(total)` | The total that rules such as `basket.total` compare against. Defaults to the sum of `qty * unitPriceMinor` over the lines. Set it when your cart total differs from that sum. | | `GronaBasket.empty(currency)` | An empty basket. Push it when the cart is cleared, for example after an order. | ## When to push [Section titled “When to push”](#when-to-push) 1. Push once at launch, as soon as your cart is loaded, so the first screen sees the right basket. 2. Push after every change: an add, a removal, a quantity change, a stock change, a cleared cart. Hook it into your cart store’s change listener, so changes from Grona actions and from your own screens take the same path. 3. Build the lines and the total from the same cart state. If your store notifies listeners outside its lock, read the state the listener receives instead of reading the store again, so one snapshot never mixes two states. `setBasket` is safe from any thread. Each push replaces the previous snapshot. Live components, such as a free-delivery progress bar, and live decisions re-evaluate against it. What the basket drives: * `basket.total`, `basket.count` (units: the sum of quantities), `basket.items` and `basket.out_of_stock` in audience rules. * The progress component, which is bound live to `basket.total`. * Substitute decisions on `cart_line`, which show only while the line is in the basket and out of stock. * Decisions that exclude products already in the basket. The basket snapshot stays on the device. Events never carry the full basket. Exception: on keyed placements such as `cart_line`, campaign events carry the line’s product id as their `key`, in every arm. So while a `cart_line` campaign is live, the product ids of the cart lines bound to it leave the device. `Grona.reset()` clears the snapshot. Caution A stale basket is the most common cause of a progress bar or substitute that looks wrong. If it lags behind the cart screen, check that every path that changes the cart ends in `setBasket`. ## Set the context [Section titled “Set the context”](#set-the-context) `GronaContext` holds the shopper’s situation. Push it at launch and whenever one of its values changes, for example when the shopper picks another store or switches between delivery and pickup.
```kotlin
import ai.grona.sdk.Grona
import ai.grona.sdk.GronaContext
Grona.setContext(
GronaContext.Builder()
.locale("en")
.branchId("store-0042")
.deliveryType("pickup")
.build(),
)
```
| Builder method | Default | Rule field | | -------------------- | ------- | ------------------------------------------------------------------------ | | `locale(locale)` | `"en"` | `context.locale`. Also picks the language of localised campaign text. | | `branchId(id)` | `null` | `context.branch`. Your store or branch id. | | `deliveryType(type)` | `null` | `context.delivery_type`. Your own value, such as `delivery` or `pickup`. | The branch and delivery type are your own strings. Use the same values in your campaign rules. Signed-in state is not part of the context: rules read `context.signed_in` from [`Grona.identify`](/mobile/android/identify-shoppers/). The screen the shopper is on is set per slot and per decision, not here. See [Render Grona components](/mobile/android/render-components/). ## Next steps [Section titled “Next steps”](#next-steps) * [Render Grona components](/mobile/android/render-components/) in your screens. * [Track events](/mobile/android/track-events/) from your analytics layer.
# Consent
> Pass the shopper's consent answer to Grona and see what the SDK stores, sends and deletes in each consent state.
This page connects your consent flow to Grona. The SDK starts in `UNKNOWN`, where it sends no events. You pass the shopper’s answer with `Grona.setConsent` at launch and whenever it changes. Campaigns render in every state: consent controls what the SDK stores and sends, not what the shopper sees. ## Set consent [Section titled “Set consent”](#set-consent) 1. At launch, right after `Grona.init`, pass the answer your app stored:
```kotlin
import ai.grona.sdk.Grona
import ai.grona.sdk.GronaConsent
Grona.setConsent(
when (consentStore.answer) {
AcmeConsent.ACCEPTED -> GronaConsent.GRANTED
AcmeConsent.DECLINED -> GronaConsent.DENIED
AcmeConsent.NOT_ASKED -> GronaConsent.UNKNOWN
},
)
```
2. Call `setConsent` again whenever the shopper changes their answer, for example from your privacy settings screen. `setConsent` is safe from any thread. Which purpose in your consent text covers Grona is your decision and your counsel’s. ## What each state does [Section titled “What each state does”](#what-each-state-does) | `GronaConsent` | Events | Stored on the device | | ------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------ | | `UNKNOWN` (default) | Kept in memory only, at most 500, oldest dropped. Nothing is sent. | No events. | | `GRANTED` | Stored and sent. Events buffered before consent are dropped, unless `sendPreConsentEvents` is on. | The event queue, at most 1,000 events or 7 days. | | `DENIED` | Buffer dropped, nothing collected. | The event queue and preview pins are deleted. | Under every state: * Campaigns still render. Evaluation happens on the device. * The configuration fetch carries no identifiers. * The install id and the configuration state stay on the device. Under `DENIED` nothing sends them. Deleting them would move shoppers between arms and weaken the configuration’s rollback protection. Under `DENIED` the SDK also refuses [preview pairing](/mobile/android/preview-links/), because the pairing request carries the install id. ## Pre-consent events [Section titled “Pre-consent events”](#pre-consent-events) Events from before the shopper answered are kept in memory. By default they are dropped when consent is granted, so only events after the answer are sent.
```kotlin
GronaOptions.Builder("acme-market")
.sendPreConsentEvents(true)
// ...
.build()
```
With `sendPreConsentEvents(true)`, the SDK sends the buffered events once consent is granted. The option is off by default. Caution Turn on `sendPreConsentEvents` only if your counsel confirms that your consent text covers events collected before the shopper answered. ## Erasure [Section titled “Erasure”](#erasure) `Grona.reset()` clears the event queue, the customer id, the profile, grants, the basket snapshot, preview pins and the session, and issues a new install id. Call it for an erasure request on the device. For data already sent, see [Google Play Data safety](/mobile/android/play-data-safety/#deletion). ## Next steps [Section titled “Next steps”](#next-steps) * Read [Consent and data minimisation](/mobile/concepts/consent/). * [Connect offers and grants](/mobile/android/offers-and-grants/).
# Handle actions
> Implement GronaHost so Grona can look up products, add to the cart, swap lines and navigate through your app's own code.
This page implements `GronaHost`, the interface Grona uses to act inside your app. When a shopper taps a Grona call to action, or your code calls an action on a [decision](/mobile/android/host-rendered-decisions/), the SDK checks the request and then calls your host. Your cart, your catalogue and your router do the work, so prices, stock rules and navigation stay yours. ## The interface [Section titled “The interface”](#the-interface) | Method | Called when | Return | | ---------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------- | | `product(productId)` | The SDK needs a product’s name, price, image or stock state, for copy or to check an action | A `GronaProduct`, or `null` when the product is unknown | | `add(lines, source)` | The shopper tapped an add on a Grona banner, or your code called `add` or `addAll` on a decision | A `GronaAddResult` | | `replace(productId, with, source)` | Your code called `replace` on a substitute decision | A `GronaAddResult` | | `navigate(route, source)` | The shopper tapped a Grona call to action that opens a screen | `true` when a screen opened, `false` otherwise | The SDK calls these methods on the main thread only. It calls `add`, `replace` and `navigate` only from a shopper’s tap or from your own call on a decision, never on a timer or on its own. It calls `product` whenever it resolves content for a slot or a decision. That can follow a basket change, a grant, a refresh or a timer, so keep `product` a fast in-memory lookup with no side effects. ## A complete host [Section titled “A complete host”](#a-complete-host) AcmeGronaHost.kt
```kotlin
package com.example.acmemarket
import ai.grona.sdk.GronaAddResult
import ai.grona.sdk.GronaHost
import ai.grona.sdk.GronaLine
import ai.grona.sdk.GronaProduct
import ai.grona.sdk.GronaRoute
import ai.grona.sdk.GronaSource
class AcmeGronaHost(private val app: AcmeMarketApp) : GronaHost {
override fun product(productId: String): GronaProduct? {
val p = app.catalog.find(productId) ?: return null // in memory, no I/O
return GronaProduct.Builder(p.id, p.name, p.priceMinor)
.wasPriceMinor(p.wasPriceMinor)
.imageUri(p.imageUrl)
.inStock(p.inStock)
.build()
}
override fun add(lines: List, source: GronaSource): GronaAddResult {
val added = lines.filter { app.cart.add(it.productId, it.qty) }
return when {
added.isEmpty() -> GronaAddResult.REJECTED
added.size < lines.size -> GronaAddResult.PARTIAL
else -> GronaAddResult.ACCEPTED
}
}
override fun replace(productId: String, with: GronaLine, source: GronaSource): GronaAddResult {
if (!app.cart.contains(productId)) return GronaAddResult.REJECTED
val ok = app.cart.replace(productId, with.productId, with.qty)
return if (ok) GronaAddResult.ACCEPTED else GronaAddResult.REJECTED
}
override fun navigate(route: GronaRoute, source: GronaSource): Boolean {
val id = route.id
return when (route.type) {
GronaRoute.HOME -> app.router.openHome()
GronaRoute.DEALS -> app.router.openDeals()
GronaRoute.CART -> app.router.openCart()
GronaRoute.CHECKOUT -> app.router.openCheckout()
GronaRoute.PRODUCT -> id != null && app.router.openProduct(id)
GronaRoute.CATEGORY -> id != null && app.router.openCategory(id)
else -> false // a route type from a newer configuration
}
}
}
```
Here `catalog`, `cart` and `router` stand for your app’s own classes. Each `router` call returns `true` when it opened a screen. The cart changes go through your normal cart code, so your cart listener [pushes the new basket](/mobile/android/basket-and-context/) to Grona as it does for any other change. ## Product lookups [Section titled “Product lookups”](#product-lookups) `product()` must be a fast in-memory lookup. The SDK calls it on the main thread to check actions and to fill copy, so do not read the disk or the network inside it. Return `null` for products you do not know. | `GronaProduct.Builder` | Default | Meaning | | -------------------------------------- | -------- | -------------------------------------------------------------------------------- | | `Builder(productId, name, priceMinor)` | Required | The product id, the display name and the price in minor units, so `349` is 3.49. | | `wasPriceMinor(price)` | `null` | A previous price, for strike-through copy. | | `imageUri(uri)` | `null` | The product image. | | `inStock(inStock)` | `true` | Whether the product can be added now. | Prices, totals and product names in Grona copy always come from this data, never from the configuration. A product your host does not know, or reports out of stock, is dropped from decisions and from add actions. ## Add results [Section titled “Add results”](#add-results) | `GronaAddResult` | Return it when | | ---------------- | -------------------------------------------------- | | `ACCEPTED` | Every line was added, or the replacement was made. | | `PARTIAL` | Some lines were added and some were not. | | `REJECTED` | Nothing changed. | The SDK logs the result with the action, so return what actually happened in your cart. ## Add caps [Section titled “Add caps”](#add-caps) Two options bound what one action may add. Both default to 5 and accept 1 to 20. | Option | Limit | | ---------------- | -------------------------------------------------- | | `maxAddQuantity` | The highest quantity of one product in one action. | | `maxAddLines` | The most lines in one action. | Before calling `add`, the SDK keeps only lines with a quantity from 1 to `maxAddQuantity`, a product your host knows and reports in stock, and no repeated product. It keeps at most `maxAddLines` of them. If no line is left, the action returns `REJECTED` and your host is not called. A replacement uses the cart line’s quantity, capped at `maxAddQuantity`. Set the caps in [`GronaOptions`](/mobile/android/initialize/#options) if your cart has its own limits:
```kotlin
GronaOptions.Builder("acme-market")
.maxAddQuantity(3)
.maxAddLines(4)
// ...
.build()
```
## Routes [Section titled “Routes”](#routes) `GronaRoute` is a typed destination. Grona never passes raw links, so your own router decides what each route opens. | Constant | `type` | `id` | | --------------------- | ---------- | --------------------------------- | | `GronaRoute.HOME` | `home` | `null` | | `GronaRoute.PRODUCT` | `product` | A product id, such as `sku_10432` | | `GronaRoute.CATEGORY` | `category` | A category id, such as `dairy` | | `GronaRoute.CART` | `cart` | `null` | | `GronaRoute.CHECKOUT` | `checkout` | `null` | | `GronaRoute.DEALS` | `deals` | `null` | Return `false` for a route type you do not recognise, an id you cannot find, or a screen you cannot open right now, such as checkout with an empty cart. Return `true` only when a screen actually opened. ## Sources [Section titled “Sources”](#sources) Every call carries a `GronaSource` that says which campaign the action came from. | Property | Meaning | | ------------ | --------------------------------- | | `placement` | The placement, such as `home_top` | | `campaignId` | The campaign | | `variantId` | The variant within the campaign | You do not need it to complete the action. Use it if your own analytics should record where an add came from. ## Next steps [Section titled “Next steps”](#next-steps) * [Track events](/mobile/android/track-events/) from your analytics layer. * [Connect offers and grants](/mobile/android/offers-and-grants/).
# Host-rendered decisions
> Ask Grona for a product decision at a placement and render it with your own product views and cart buttons.
This page renders Grona decisions with your app’s own views. A decision tells you which products to show at a placement, such as a frequently-bought-together rail under the buy button, and you draw them with the product cells you already have. Use this when the content must look like the rest of your catalogue. For banners, progress bars, offers and messages, use a [`GronaSlotView`](/mobile/android/render-components/) instead. ## Decision types [Section titled “Decision types”](#decision-types) | Constant | Value | Typical use | | ---------------------------- | -------------- | ---------------------------------------------------------------- | | `GronaDecision.RAIL` | `rail` | A row of products, for example in the home feed | | `GronaDecision.BUNDLE` | `bundle` | Products to buy together, with an add-all button | | `GronaDecision.PRODUCT_CARD` | `product_card` | One featured product | | `GronaDecision.SUBSTITUTE` | `substitute` | A replacement for an out-of-stock cart line, on `cart_line` only | Treat any other `type` as no decision. Newer configurations may add types that your app does not know how to draw. ## Request a decision [Section titled “Request a decision”](#request-a-decision) 1. Add an anchor to the layout: the view the content will occupy. A `FrameLayout` with `wrap_content` height works. Keep it `VISIBLE`. res/layout/activity_product.xml
```xml
```
2. Call `Grona.decision` on the main thread with the anchor, the placement, the screen and a listener: ProductActivity.kt
```kotlin
import ai.grona.sdk.Grona
import ai.grona.sdk.GronaDecision
import ai.grona.sdk.GronaScreen
import ai.grona.sdk.GronaSubscription
class ProductActivity : AppCompatActivity() {
private var together: GronaSubscription = GronaSubscription.NONE
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_product)
val anchor = findViewById(R.id.togetherAnchor)
val productId = intent.getStringExtra("productId") ?: return
together = Grona.decision(anchor, "pdp_below_buy", GronaScreen.product(productId)) { decision ->
if (decision != null && decision.type == GronaDecision.BUNDLE) {
showTogether(anchor, decision)
} else {
anchor.removeAllViews() // stays VISIBLE, wraps to zero height
}
}
}
}
```
3. Render the decision with your own views. Take names, prices and images from your catalogue, and wire your buttons to the decision’s actions:
```kotlin
private fun showTogether(anchor: FrameLayout, decision: GronaDecision) {
anchor.removeAllViews()
val rail = layoutInflater.inflate(R.layout.together_rail, anchor, false)
rail.findViewById(R.id.title).text = decision.title ?: getString(R.string.together_default_title)
val list = rail.findViewById(R.id.products)
decision.products.forEach { p ->
val cell = productCell(p.productId) // your own product cell
cell.setOnAddClick { decision.add(p.productId) }
list.addView(cell)
}
rail.findViewById(R.id.addAll).apply {
text = decision.ctaLabel ?: getString(R.string.add_all)
setOnClickListener { decision.addAll() }
}
anchor.addView(rail)
}
```
The listener runs on the main thread, never synchronously inside `decision`, and runs again whenever the decision changes. `null` means show nothing. Caution Keep the anchor `VISIBLE` when the decision is `null`: leave it empty at zero height. Grona measures `anchor_visible` on the anchor for every arm. A `GONE` or `INVISIBLE` anchor is never measured, so the control arm would go unmeasured. Debuggable apps log a warning once per hidden anchor. ## Keyed placements [Section titled “Keyed placements”](#keyed-placements) `cart_line` decisions are per line. Pass the line’s product id as the key:
```kotlin
Grona.decision(rowView, "cart_line", GronaScreen.CART, line.productId) { decision ->
bindSubstitute(rowView, decision?.takeIf { it.type == GronaDecision.SUBSTITUTE })
}
```
A substitute shows only while its line is in the basket and out of stock, so [push the basket](/mobile/android/basket-and-context/) with `inStock = false` for that line. Call `decision.replace(withProductId)` from the shopper’s tap to swap the line. ## The `GronaDecision` object [Section titled “The GronaDecision object”](#the-gronadecision-object) | Property | Type | Meaning | | ---------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `placement` | `String` | The placement it was requested for. | | `campaignId` | `String` | The campaign it came from. | | `variantId` | `String` | The variant within that campaign. | | `type` | `String` | `rail`, `bundle`, `product_card` or `substitute`. | | `title`, `subtitle`, `badge`, `ctaLabel` | `String?` | Copy from the configuration. Each may be `null`. | | `products` | `List` | The products to show, each with `productId`, `qty` and an optional `label`. Products your app does not know or has out of stock are already removed. | | `key` | `String?` | For keyed placements such as `cart_line`, the line this decision belongs to. | | `extras` | `Map` | Layout hints from the configuration, for example `position` for feed rows. | | `isPreview` | `Boolean` | `true` while a [preview pin](/mobile/android/preview-links/) produced this decision. You may badge it. | | `source` | `GronaSource` | Placement, campaign and variant, as passed to `GronaHost`. | A decision with no products left is not delivered, so `products` is never empty. ### Render it safely [Section titled “Render it safely”](#render-it-safely) * Render `title`, `subtitle`, `badge`, `ctaLabel` and product labels as plain text. No HTML, no `Linkify`, no Markdown. * Treat `extras` as untrusted hints. Check each value against what your layout accepts and ignore anything else. * Take product names, prices and images from your own catalogue, never from the decision. ## Actions [Section titled “Actions”](#actions) Call these from the shopper’s own tap, on the main thread. `add`, `addAll` and `replace` check the request, log the action, call your `GronaHost` and return a `GronaAddResult`. `dismiss` logs the dismissal and returns nothing. | Method | What it does | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `add(productId)` | Adds one unit of a product in the decision through `GronaHost.add`. | | `add(productId, qty)` | Adds `qty` units. | | `addAll()` | Adds every product in the decision at its `qty`. | | `replace(withProductId)` | For substitutes: replaces the `key` line with `withProductId` through `GronaHost.replace`, at the line’s current quantity (capped at `maxAddQuantity`). | | `dismiss()` | Hides this decision for the rest of the session. | Before it calls your host, the SDK drops lines whose product is not in the decision, whose quantity is outside 1 to `maxAddQuantity`, that repeat a product, or that your `GronaHost.product` does not know or reports out of stock. It keeps at most `maxAddLines` lines. If nothing is left, or the decision is no longer the current one for its anchor, the call returns `REJECTED` without calling your host. When an action returns `REJECTED`, the shopper still asked for the product. You may fall back to a plain add through your own cart code. The SDK has logged the action with its `rejected` result, unless the decision was no longer current. A call on a decision that is no longer current, or off the main thread, returns `REJECTED` without logging the action. Off the main thread the SDK also sends a warning to your `GronaLogger`. ## Subscriptions and rebinding [Section titled “Subscriptions and rebinding”](#subscriptions-and-rebinding) `Grona.decision` returns a `GronaSubscription`. * The subscription drops with the anchor view. You do not have to cancel it when the screen closes. * Binding the same anchor again replaces the previous subscription. RecyclerView rows can rebind freely in `onBindViewHolder`. * The same placement, screen and key on another view continues the visit while any view for it is attached, and for 2 seconds after. So holders that rebind log `eligible` and `served` once. * `cancel()` ends the visit. Cancel and request again when you want a fresh visit on the same screen, for example after [`Grona.refresh`](/mobile/android/initialize/#refresh-on-demand) brings a campaign back.
```kotlin
override fun onBindViewHolder(holder: CartLineHolder, position: Int) {
val line = lines[position]
holder.bind(line)
Grona.decision(holder.itemView, "cart_line", GronaScreen.CART, line.productId) { decision ->
holder.showSubstitute(decision?.takeIf { it.type == GronaDecision.SUBSTITUTE })
}
}
```
If you call `decision` off the main thread, the SDK logs a warning and posts the call to the main thread. ## Next steps [Section titled “Next steps”](#next-steps) * [Handle actions](/mobile/android/handle-actions/) in your `GronaHost`. * Read [Patterns for host-rendered decisions](/mobile/concepts/host-rendered-patterns/).
# Identify shoppers
> Tell Grona which customer is signed in, pass their profile from your own session and clear both at logout.
This page connects Grona to your sign-in flow. Identify the shopper when they sign in or when your app restores a session, pass their profile so audience rules can read it, and clear both at logout. Shoppers who are not identified still see campaigns that target installs, but never campaigns that target customers, such as every offer. ## Identify the shopper [Section titled “Identify the shopper”](#identify-the-shopper) 1. Call `Grona.identify` with your internal customer id as soon as you know it: after sign-in, and at launch when your app restores a saved session.
```kotlin
import ai.grona.sdk.Grona
fun onSignedIn(session: AcmeSession) {
Grona.identify(session.customerId) // "cus_7Hq2Lm9x"
}
```
2. Call `Grona.setProfile` with data from your own authenticated session. Bind the profile to the same customer id.
```kotlin
import ai.grona.sdk.Grona
import ai.grona.sdk.GronaProfile
val profile = GronaProfile.Builder("cus_7Hq2Lm9x")
.segment("returning_buyer")
.list("usual_items", listOf("sku_10432", "sku_20877"))
.attribute("store_format", "express")
.build()
Grona.setProfile(profile)
```
3. At logout, call `Grona.identify(null)` or `Grona.reset()`. See [Log out](#log-out). Both calls are safe from any thread. Call `identify` before `setProfile`, because a profile for any other customer is ignored. ## Rules for customer ids [Section titled “Rules for customer ids”](#rules-for-customer-ids) * **Opaque and internal.** Pass the id your systems use for the customer, such as `cus_7Hq2Lm9x`. Never pass an email address, a phone number or a national id. * **Format.** The id must match `[A-Za-z0-9._:-]{1,128}`. Any other value is ignored and the SDK logs a warning. * **All-digit ids.** The SDK cannot tell an opaque number from a phone number. It accepts an all-digit id of 9 or more characters but logs a warning. Do not pass one. * **Stable.** Use the same id on every device and on your backend. Customer-unit campaigns assign arms from it, and your [grant backend](/mobile/backend/grants-and-orders/) checks grants against it. Passing the id that is already identified does nothing. Passing a different id clears the profile and any grants, then starts a new grant fetch if you set a [grant source](/mobile/android/offers-and-grants/). ## Set the profile [Section titled “Set the profile”](#set-the-profile) A profile carries per-shopper data that audience rules read through the `profile.*` fields. It comes only from your app, from your own authenticated session. Grona never serves profiles itself. | Builder method | Rule field | What it holds | | ---------------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------- | | `GronaProfile.Builder(customerId)` | | The customer the profile belongs to. It must equal the id passed to `identify`. | | `segment(id)`, `segments(ids)` | `profile.segments` | Opaque segment ids, such as `returning_buyer`. | | `list(name, productIds)` | `profile.lists.` | A named list of product ids, such as the shopper’s usual items. Decisions can draw products from it. | | `attribute(name, value)` | `profile.attributes.` | A string attribute, such as a store format. | Until a matching profile is set, every rule that reads `profile.*` is unknown, and a campaign whose audience is unknown stays inactive. So a profile-targeted campaign never shows to a shopper who is not identified, not even through a `not` rule. When the profile arrives, live placements re-evaluate. The debug view shows `audience: profile unknown` for campaigns waiting on one. Call `Grona.setProfile(null)` to clear the profile while the shopper stays identified. ## Log out [Section titled “Log out”](#log-out) Two calls end a signed-in session. They clear different amounts. | Call | Clears | Keeps | | ---------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | | `Grona.identify(null)` | Customer id, profile, grants | Install id, event queue, basket snapshot, preview pins | | `Grona.reset()` | Customer id, profile, grants, event queue, basket snapshot, preview pins and session, and issues a new install id | Configuration state | Use `reset()` for erasure requests and wherever the next shopper on the device must not inherit this install’s history. A new install id assigns install-unit campaigns afresh, so the device may move between arms. After `reset()`, push the basket again if the cart is not empty. ## Next steps [Section titled “Next steps”](#next-steps) * [Push the basket and context](/mobile/android/basket-and-context/). * Read [Shoppers and identity](/mobile/concepts/identity/) for how install and customer units work.
# Initialise the SDK
> Start Grona from Application.onCreate, choose its options and connect the host interface your app implements.
This page starts the SDK when your app launches. You build a `GronaOptions` object, implement `GronaHost` so Grona can reach your cart and router, and call `Grona.init` once from your `Application` class. Every other Grona call does nothing until `init` has run. ## Start Grona at launch [Section titled “Start Grona at launch”](#start-grona-at-launch) Grona provides the workspace key, the URLs and the signing keys for your workspace. The values below are Acme Market’s; replace them with yours. 1. Create an `Application` subclass, or open the one your app already has, and register it in `AndroidManifest.xml`: app/src/main/AndroidManifest.xml
```xml
```
2. Add the signed bundled configuration that Grona exports for your workspace to your assets, at `app/src/main/assets/grona/config.json`. The SDK applies it at launch and whenever it is offline. 3. Build the options and call `Grona.init` from `onCreate`: AcmeMarketApp.kt
```kotlin
package com.example.acmemarket
import ai.grona.sdk.Grona
import ai.grona.sdk.GronaOptions
import android.app.Application
class AcmeMarketApp : Application() {
override fun onCreate() {
super.onCreate()
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...")
.eventsUrl("https://api.grona.ai/v1/mobile/events")
.previewUrl("https://api.grona.ai/v1/mobile/preview/pair")
.build()
Grona.init(this, options, AcmeGronaHost(this))
}
}
```
4. Implement `AcmeGronaHost` (outline below), then pass the shopper’s [consent](/mobile/android/consent/), [identity](/mobile/android/identify-shoppers/) and [basket](/mobile/android/basket-and-context/) as your app learns them. ## How `init` behaves [Section titled “How init behaves”](#how-init-behaves) * **Constant time.** `init` queues its work on a Grona background thread and returns. It does no disk or network work on the main thread, apart from reading its process name on Android 8.1 and lower. * **Never throws.** If start-up fails, the SDK logs through your `GronaLogger` and stays inactive. Your app carries on. * **Once only.** A second call is ignored, so you cannot change options after launch. * **Main process only.** In any other process `init` is ignored and Grona stays inactive there. If your app runs extra processes, do not call `Grona.init` from them. When the process name cannot be read at all, the SDK assumes it is the main process. * **Thread rules.** Setters such as `setBasket` and `identify` are safe from any thread. `Grona.decision` must be called on the main thread. The SDK calls your `GronaHost` on the main thread only. At start the SDK applies the bundled configuration, then fetches the remote one. It never refreshes the configuration more often than every 300 seconds. For how configurations are signed and checked, see [Signed configuration](/mobile/concepts/signed-configuration/). Caution `GronaOptions.Builder.build()` is the one call that throws. It raises `IllegalArgumentException` for invalid input, such as an `http://` URL or no `configKey`. Build the options with fixed values so the error shows up the first time you run the app. ## Options [Section titled “Options”](#options) Every option is set on `GronaOptions.Builder`. Only the workspace key and one `configKey` are required. | Option | Default | Validation | What it does | | ------------------------------- | ------------------------------ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `Builder(workspaceKey)` | Required | Matches `[A-Za-z0-9._:-]{1,128}` | Your workspace key, for example `acme-market`. It is a public identifier, not a secret. | | `configUrl(url)` | None | Must start with `https://` | Signed remote configuration. Without it the SDK runs on the bundled copy only. | | `bundledConfigAsset(path)` | None | | Asset path of the signed bundled configuration, applied at launch and offline. | | `configKey(kid, publicKeySpki)` | None | At least one is required | A trusted configuration signing key: the key id and the base64 DER SubjectPublicKeyInfo of a P-256 public key. Add the current and the next key. | | `grantKey(kid, publicKeySpki)` | None | | A trusted grant signing key, same format as `configKey`. Needed for [offers](/mobile/android/offers-and-grants/). | | `eventsUrl(url)` | None | Must start with `https://` | Events endpoint. Without it the SDK sends no events and keeps no event queue. Debuggable builds keep the last 200 events in memory for the debug view. | | `previewUrl(url)` | None | Must start with `https://` | Preview pairing endpoint. Without it [preview links](/mobile/android/preview-links/) can only clear pins. | | `activationSource(source)` | None | | A `GronaActivationSource` that can switch campaigns off. See [Activation source](#activation-source). | | `grantSource(source)` | None | | A `GronaGrantSource` that fetches grants from your backend. See [Offers and grants](/mobile/android/offers-and-grants/). | | `theme(theme)` | `GronaTheme.Builder().build()` | | Fonts, colours and corner radius for Grona components. See [Theming](/mobile/android/theming/). | | `logger(logger)` | `GronaLogger.NONE` | | Receives SDK diagnostics. Silent by default. See [Test and debug](/mobile/android/test-and-debug/). | | `maxAddQuantity(max)` | `5` | 1 to 20 | Highest quantity one Grona action may add. See [Handle actions](/mobile/android/handle-actions/). | | `maxAddLines(max)` | `5` | 1 to 20 | Most lines one Grona action may add. | | `sendPreConsentEvents(enabled)` | `false` | | Send events buffered before consent once consent is granted. See [Consent](/mobile/android/consent/). | | `demoMode(enabled)` | `false` | | Demos only, never in production. See [Offers and grants](/mobile/android/offers-and-grants/). | ## Implement `GronaHost` [Section titled “Implement GronaHost”](#implement-gronahost) `GronaHost` is how Grona works through your app’s own code. The SDK never reaches into your app any other way. It calls these four methods on the main thread only: AcmeGronaHost.kt
```kotlin
package com.example.acmemarket
import ai.grona.sdk.GronaAddResult
import ai.grona.sdk.GronaHost
import ai.grona.sdk.GronaLine
import ai.grona.sdk.GronaProduct
import ai.grona.sdk.GronaRoute
import ai.grona.sdk.GronaSource
class AcmeGronaHost(private val app: AcmeMarketApp) : GronaHost {
// A fast in-memory lookup in your catalogue; null when the product is unknown
override fun product(productId: String): GronaProduct? = app.catalog.gronaProduct(productId)
// Add lines to the cart after the shopper tapped a Grona call to action
override fun add(lines: List, source: GronaSource): GronaAddResult = app.cart.addFromGrona(lines)
// Swap one cart line, for example an out-of-stock item for its substitute
override fun replace(productId: String, with: GronaLine, source: GronaSource): GronaAddResult =
app.cart.replaceFromGrona(productId, with)
// Open the route with your own router; false when the route is unknown
override fun navigate(route: GronaRoute, source: GronaSource): Boolean = app.router.open(route)
}
```
Here `catalog`, `cart` and `router` stand for your app’s own classes. [Handle actions](/mobile/android/handle-actions/) has a complete implementation and the rules for each return value. ## Activation source [Section titled “Activation source”](#activation-source) An activation source lets an external campaign system switch Grona campaigns off. Each campaign can list external ids, such as `CMP-1024`. When your source reports which external ids are active, a campaign that lists ids stays active only while at least one of them is active. Activation can only switch campaigns off, never on, and campaigns without external ids ignore it.
```kotlin
import ai.grona.sdk.GronaActivationSource
class AcmeActivationSource(private val campaigns: AcmeCampaignClient) : GronaActivationSource {
// Called on a Grona background thread; may block for up to 10 seconds
override fun activeExternalIds(): Set? =
try {
campaigns.fetchActiveIds() // for example setOf("CMP-1024")
} catch (e: java.io.IOException) {
null // unknown: Grona does not narrow anything
}
}
```
* Return `null` when you do not know. Unknown never switches a campaign off. * The SDK polls the source every 5 minutes while the app is in the foreground. An answer is trusted for at most 10 minutes. A poll that fails, times out or finds the previous call still running makes activation unknown at once. * Past 10 seconds the SDK stops waiting and treats the answer as unknown. It calls the source again only once the earlier call has returned. * The source runs on Grona’s background threads, sometimes at the same time as your own code. It must be thread-safe and must not touch views. For how activation fits the kill switch, see [Campaign activation and kill switch](/mobile/concepts/activation/). ## Refresh on demand [Section titled “Refresh on demand”](#refresh-on-demand) The SDK refreshes on its own. When you need a refresh now, for example after a pull-to-refresh, call one of these:
```kotlin
Grona.refresh() // no completion signal
Grona.refresh {
// Main thread, called once, also on timeouts and failures
}
```
Both poll the activation source and ask for a configuration fetch, subject to the 300-second floor. The listener form waits for an activation answer requested after the call (10-second timeout), then for the fetch and re-evaluation, before it runs. A campaign that was switched off stays off for the rest of the screen visit, even after it is switched back on. To show it at once, end the visit from the listener by cancelling the decision’s `GronaSubscription` or setting the slot’s `screen` to `null`, then bind again. See [Render Grona components](/mobile/android/render-components/#screen-visits). ## Next steps [Section titled “Next steps”](#next-steps) * [Identify shoppers](/mobile/android/identify-shoppers/) when they sign in. * [Set consent](/mobile/android/consent/) from your consent flow. * [Push the basket and context](/mobile/android/basket-and-context/).
# Install the SDK
> Add the Grona Android SDK to your app from Maven Central and check the build requirements it sets.
This page adds the Grona Android SDK to your app’s Gradle build. Do it once per app module that shows Grona content. When the build syncs, go on to [initialise the SDK](/mobile/android/initialize/). ## Requirements [Section titled “Requirements”](#requirements) Check these before you add the dependency. The SDK’s build metadata enforces the first two, so a project that misses them fails to build instead of failing at runtime. | Requirement | Value | Notes | | ----------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Minimum Android version | Android 7.0 (API level 24) | Your app’s `minSdk` must be 24 or higher. | | `compileSdk` | 36 or higher | The AAR metadata sets `minCompileSdk = 36`. A lower `compileSdk` fails the `checkAarMetadata` task. Use an Android Gradle Plugin version that supports API 36. | | Java bytecode | Java 11 | The SDK is compiled to Java 11 bytecode. Set your app’s `compileOptions` and Kotlin `jvmTarget` to 11 or higher. | | Kotlin | 2.1 or later | Only if your app uses Kotlin. The SDK is compiled with Kotlin language and API version 2.1 and declares Kotlin standard library 2.1.0, so it does not force a newer Kotlin on you. | | UI toolkit | Android Views | `GronaSlotView` is a `FrameLayout`. The SDK has no Jetpack Compose API in version 0.1.0. | | Permissions | `android.permission.INTERNET` | Declared in the SDK’s manifest and merged into yours. The SDK declares no other permission. | The SDK’s only runtime dependencies are the Kotlin standard library and `kotlinx-coroutines-android`. It brings no AndroidX libraries and no HTTP client. ## Add the dependency [Section titled “Add the dependency”](#add-the-dependency) The SDK ships as two artifacts on Maven Central: | Artifact | Configuration | Purpose | | ---------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------ | | `ai.grona:grona-core:0.1.0` | `implementation` | The SDK. | | `ai.grona:grona-debug:0.1.0` | `debugImplementation` only | A debug screen for your own debug builds. See [Test and debug](/mobile/android/test-and-debug/). | 1. Make sure Maven Central is one of your repositories. New Android projects list it in `settings.gradle.kts`: settings.gradle.kts
```kotlin
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
```
2. Add the two artifacts to your app module. Use the version catalogue if your project has one. * Version catalogue gradle/libs.versions.toml
```toml
[versions]
grona = "0.1.0"
[libraries]
grona-core = { module = "ai.grona:grona-core", version.ref = "grona" }
grona-debug = { module = "ai.grona:grona-debug", version.ref = "grona" }
```
app/build.gradle.kts
```kotlin
dependencies {
implementation(libs.grona.core)
debugImplementation(libs.grona.debug)
}
```
* Kotlin DSL app/build.gradle.kts
```kotlin
dependencies {
implementation("ai.grona:grona-core:0.1.0")
debugImplementation("ai.grona:grona-debug:0.1.0")
}
```
3. Check the module’s Java and Kotlin targets meet the requirements above: app/build.gradle.kts
```kotlin
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
android {
compileSdk = 36
defaultConfig {
minSdk = 24
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
```
Higher values, such as Java 17, are fine. 4. Sync Gradle and build the app once. If `checkAarMetadata` fails, raise `compileSdk` to 36. Caution Add `grona-debug` with `debugImplementation` only. The debug screen refuses to open in an app that is not debuggable, but it has no reason to be in your release build. ## What the SDK adds to your app [Section titled “What the SDK adds to your app”](#what-the-sdk-adds-to-your-app) * **One permission:** `INTERNET`, for the signed configuration, events, preview pairing and campaign images from your allowed https hosts. * **No R8 rules.** The SDK’s consumer rules file is empty and your app needs no extra rules. See [R8 and ProGuard](/mobile/android/r8-and-proguard/). * **Resources prefixed `grona_`.** The debug artifact’s resources use `grona_debug_`. * **One activity, from `grona-debug` only.** It is not exported and has no intent filters. The SDK never reads the advertising ID, OAID, `ANDROID_ID` or App Set ID. For what it sends and when, see [Google Play Data safety](/mobile/android/play-data-safety/). ## Next steps [Section titled “Next steps”](#next-steps) * [Initialise the SDK](/mobile/android/initialize/) from your `Application` class. * Read [Rendering modes](/mobile/concepts/rendering-modes/) to decide where Grona draws and where your app draws.
# Offers and grants
> Connect a grant source so money-bearing offers can render, then send their grant tokens with the order.
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”](#how-it-fits-together) 1. The shopper signs in and your app calls [`Grona.identify`](/mobile/android/identify-shoppers/). 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](/mobile/backend/grants-and-orders/) and the [Mobile grants API](/api/mobile-grants/). Caution Your app never calls Grona’s grant endpoint and never holds the secret key. The grant endpoint takes your secret API key, which belongs on your server only. ## Implement the grant source [Section titled “Implement the grant source”](#implement-the-grant-source) `GronaGrantSource` has one method. It returns the grants as compact JWS strings, or `null` on failure. AcmeGrantSource.kt
```kotlin
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? =
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. ## Configure the keys and the source [Section titled “Configure the keys and the source”](#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.
```kotlin
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](/mobile/backend/key-rotation/). ## When grants are fetched [Section titled “When grants are fetched”](#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(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. ## Send treatments with the order [Section titled “Send treatments with the order”](#send-treatments-with-the-order) At checkout, read the treatments the shopper can currently see and send their tokens to your order API.
```kotlin
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](/mobile/android/preview-links/) 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. ## Demo mode [Section titled “Demo mode”](#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. Danger Never ship `demoMode(true)` in a production app. A real order backend must ignore any treatment without a valid token. ## Next steps [Section titled “Next steps”](#next-steps) * Set up [Grants and order verification](/mobile/backend/grants-and-orders/) on your server. * Read [Offers and grants](/mobile/concepts/offers-and-grants/) for how offers are analysed.
# Google Play Data safety
> What the Grona Android SDK collects and sends, mapped to the questions in Google Play's Data safety form.
This page helps your app team fill in Google Play’s Data safety form for the Grona SDK. Play’s form covers user data that libraries and SDKs in your app transmit off the device, so Grona’s data belongs in your answers. The page lists what the SDK sends, when and why, mapped to Play’s data types, and names the questions that need a judgement call from your team. Caution The final answers are yours. Google holds the developer responsible for complete and accurate declarations, including data that SDKs in the app send, and your answers also depend on how you configure Grona, what your consent text says and your contract with Grona. Review them with your privacy counsel. ## When the SDK sends anything [Section titled “When the SDK sends anything”](#when-the-sdk-sends-anything) Play counts data as collected when it is transmitted off the device. The Grona SDK sends events only when both of these hold: * You set an `eventsUrl` in your options. Without one, the SDK sends no events and keeps no queue. * The shopper’s consent is `GRANTED`. Under `UNKNOWN` events stay in memory and none are sent. Under `DENIED` nothing is collected. See [Consent](/mobile/android/consent/). If you turn on `sendPreConsentEvents`, events buffered under `UNKNOWN` are sent once consent is granted, so your consent text must cover them. Consent gating does not remove the entries from your form. Once any shopper can grant consent, the data is collected and must be declared. Three other requests leave the device: * **The configuration fetch** carries no identifiers in any consent state. It is not user data. * **Preview pairing** sends a pairing code and the install id when a tester opens a [preview link](/mobile/android/preview-links/). It is refused under `DENIED`. The code is a single-use tester secret that expires in minutes, not end-user data. * **Image downloads** fetch component images from the image hosts in your signed configuration. They carry no Grona identifiers, but the image host sees the device’s IP address. ## Data types [Section titled “Data types”](#data-types) With consent granted and an events endpoint set, the SDK sends the following. For every row: collected yes, encrypted in transit yes, and not ephemeral. Play counts data as ephemeral only when it is held in memory no longer than it takes to serve a request; Grona stores events and uses them for per-shopper analysis. | Play category and data type | What Grona sends | Notes | | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Device or other IDs | The SDK’s install id and session id, random UUIDs the SDK generates | Pseudonymous ids that can be re-associated with a user still count. Never the advertising ID, OAID, `ANDROID_ID` or App Set ID, which the SDK does not read. | | Personal info: User IDs | The customer id you pass to `Grona.identify` | Only if your app calls `identify`. Must be your opaque internal id. | | App activity: App interactions | Campaign events: eligible, visible, served, exposure, taps on Grona content, dismissals. Product views, cart adds and cart removals | Product ids and prices only for products a Grona campaign showed or added in the session. Other products are counted without id or price. Exception: on keyed placements such as `cart_line`, campaign events carry the line’s product id as their `key`, in every arm. | | Financial info: Purchase history | The checkout total, and the purchase event: order id, order total, currency, the Grona treatments, and the products in the order that a Grona campaign showed or added | Never the full basket. Cart line product ids can still travel as campaign event keys, see App interactions. | | App info and performance: Diagnostics | Batch metadata: SDK version, app version, platform and Android API level | A conservative choice. It is not clear that Play requires an entry for this metadata. | The SDK does not send: * Location, contacts, messages, photos, files or any other device content. * Names, email addresses or phone numbers. * Search text or browsing history. * The shopper’s profile from `Grona.setProfile`, which stays on the device. * The full basket from `Grona.setBasket`, which stays on the device. Only the product ids of cart lines bound to a keyed placement such as `cart_line` leave it, as campaign event keys. Grant fetching runs through your own `GronaGrantSource` and your own backend. Declare that data flow as part of your app, as you would any call to your own servers. ### IP addresses [Section titled “IP addresses”](#ip-addresses) Play asks you to declare Approximate location when IP addresses are used to infer location. Every request reaches Grona’s servers from the device’s IP address. Grona truncates IP addresses when it receives them and does not derive location from them, so the SDK adds no location entry to your answers. ## Purposes [Section titled “Purposes”](#purposes) Choose the purposes that match how your company uses Grona. These are the usual candidates: | Purpose | Applies to | Why | | ------------------------ | ------------------------------------ | --------------------------------------------------------------------------------------------- | | Analytics | All rows | Grona measures campaigns: who was eligible, who saw what and what they did next. | | Personalization | All rows except diagnostics | Campaigns target shoppers by audience and assign them to variants. | | App functionality | Install id, customer id, diagnostics | Customer ids gate offers and grants. Install and customer ids keep variant assignment stable. | | Advertising or marketing | All rows except diagnostics | A judgement call, see below. | **Advertising or marketing.** Grona banners and offers promote your own products to targeted shoppers and measure their exposure. A plain reading of Play’s definition suggests declaring this purpose. The counter-argument is that slots which only recommend products are Personalization alone. Decide with your counsel. If you drop it, drop it on your App Store answers too, so both stores tell the same story. ## Sharing [Section titled “Sharing”](#sharing) Play does not count a transfer to a service provider as sharing when the provider processes data on your behalf and on your instructions. “Shared: No” holds for Grona only while Grona processes your data solely on your instructions, which depends on your contract with Grona. Confirm it with your counsel. Your own use can change this answer. If your app forwards Grona ids or events to advertising platforms, that is your app’s data flow and must be declared as such. ## Optional or required [Section titled “Optional or required”](#optional-or-required) Play lets you mark collection as optional only when every user, in every region, can opt in, opt out or skip it. The SDK sends no events until your app passes `GronaConsent.GRANTED`. If your consent prompt is shown to every shopper everywhere and declining is possible, the Grona data can be declared optional. If your prompt is shown only in some regions, the answer is required. ## Security practices [Section titled “Security practices”](#security-practices) * **Encrypted in transit: yes.** The SDK uses https only and refuses http even when your app allows cleartext traffic. Play asks this once for the whole app, so your answer must hold for your other traffic too. * **Deletion.** See below. ### Deletion [Section titled “Deletion”](#deletion) `Grona.reset()` alone does not answer Play’s deletion question. It clears the device, but Play asks whether users can request deletion of data you collected. * **On the device.** `Grona.reset()` clears the event queue, the customer id, the profile, grants, the basket snapshot and preview pins, and issues a new install id. Call it from your account deletion or erasure flow. See [Identify shoppers](/mobile/android/identify-shoppers/#log-out). * **Already sent.** Give users a way to request deletion, for example through your existing account deletion flow, and pass the request on to Grona with the customer id or install id. Contact [Grona support](/get-started/support/) to set up how requests reach Grona. ## Common mistakes [Section titled “Common mistakes”](#common-mistakes) * Passing an email, phone number or hashed email as the customer id. That changes your answers on both stores and breaks Grona’s identity rules. * Answering “optional” when your consent prompt is shown only in some regions. * Treating `reset()` as your deletion mechanism. * Assuming consent gating removes the entries from the form. ## Next steps [Section titled “Next steps”](#next-steps) * Read [Consent and data minimisation](/mobile/concepts/consent/). * Read [Data privacy and security](/get-started/data-privacy-security/).
# Preview links
> Route Grona preview links to the SDK so testers can see one campaign variant on their own device before launch.
This page lets testers open a campaign variant on their own device before it goes live. A preview link carries a one-time pairing code. The SDK exchanges it for a signed pin bound to that install, so the tester sees the variant and nobody else’s experience changes. Your app routes the link to `Grona.handleLink`. ## Link format [Section titled “Link format”](#link-format) Preview links use your app’s own URL scheme with the host `grona-preview`: | Link | Effect | | ------------------------------------------------ | --------------------------------------------- | | `acmemarket://grona-preview?code=` | Pairs this install with one campaign variant. | | `acmemarket://grona-preview?clear=1` | Removes every preview pin on this device. | A code is 8 to 64 characters of letters, digits and hyphens. The SDK rejects a link with any other host, a port, user info, a path other than empty or `/`, a fragment, an extra or repeated parameter, or a query longer than 256 characters. ## Handle the link [Section titled “Handle the link”](#handle-the-link) 1. Add an intent filter for your scheme and the `grona-preview` host to the activity that handles deep links: app/src/main/AndroidManifest.xml
```xml
```
2. Pass the link to `Grona.handleLink` from `onCreate` and `onNewIntent`: MainActivity.kt
```kotlin
import ai.grona.sdk.Grona
import android.content.Intent
import android.os.Bundle
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// A launch from recents carries the old link; do not replay it
val fromHistory = intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
if (savedInstanceState == null && !fromHistory) handleDeepLink(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleDeepLink(intent)
}
private fun handleDeepLink(intent: Intent?) {
val uri = intent?.data ?: return
if (Grona.handleLink(uri)) return // a Grona preview link, accepted or not
openAppLink(uri) // your own deep-link routing
}
}
```
3. Set a `previewUrl` in your [options](/mobile/android/initialize/#options). Grona provides it for your workspace. Without it the SDK ignores codes and can only clear pins. `handleLink` returns `true` for any link whose host is `grona-preview`, whether or not the SDK accepted it, and `false` for every other link. A rejected link is logged through your `GronaLogger`. Caution A pairing code works like a password: whoever opens the link gets a pin on their own install. Share preview links only with testers. Codes are single use and expire within minutes. ## Pairing [Section titled “Pairing”](#pairing) When a code arrives, the SDK posts the code, the install id and the workspace key over https to `previewUrl`, and stores the signed pin it gets back. * Pairing is refused while consent is `DENIED`, because the request carries the install id. Under `UNKNOWN` and `GRANTED` it runs: pairing is a tester action, not analytics. * One exchange runs at a time. A link that arrives while one is in flight is ignored. * Any response other than a valid pin leaves existing pins unchanged. ## How a pin behaves [Section titled “How a pin behaves”](#how-a-pin-behaves) * **Lifetime.** A pin lasts until it expires (at most 24 hours), until a `clear=1` link, until `Grona.reset()`, or until consent becomes `DENIED`. It survives app restarts and is verified again on every load. * **One per campaign.** The newest pin for a campaign wins. * **What it bypasses.** The pinned campaign goes first in its placement and skips audience rules, holdouts, the traffic split and activation. * **What it never bypasses.** A campaign disabled in the configuration (`enabled: false`), an expired configuration, or validation. A customer-unit campaign, such as every offer, still shows only to an identified shopper. * **Control variants.** A pinned control variant renders nothing, as any control arm does. * **Marking.** SDK components draw a “Preview” chip, decisions carry `isPreview = true`, and every event carries a preview flag so analysis excludes it. * **No grants.** A pinned offer renders without a grant, but it never appears in `Grona.activeTreatments()`. Pins never produce a grant, so a preview can never change what an order costs. Debuggable builds can also pin a variant locally from the [debug view](/mobile/android/test-and-debug/), without a link. Local pins are unsigned, last at most 24 hours, and are ignored in apps that are not debuggable. ## Next steps [Section titled “Next steps”](#next-steps) * [Test and debug](/mobile/android/test-and-debug/) your integration.
# R8 and ProGuard
> The Grona SDK needs no keep rules: what its consumer rules contain, why, and what to do if R8 reports a problem.
This page covers shrinking and obfuscation in release builds. The short version: your app adds no rules for Grona. The SDK’s consumer rules file is empty on purpose, and R8 keeps everything the SDK needs through your app’s own references. ## What the SDK bundles [Section titled “What the SDK bundles”](#what-the-sdk-bundles) `grona-core` ships a consumer rules file that R8 applies to your build automatically. It contains no rules, only this note: consumer-rules.pro (bundled in grona-core)
```text
# Intentionally empty: grona-core uses no reflection, JNI or name-based serialization.
# Host R8 keeps the public API through the host's own references; AAPT keeps GronaSlotView
# when inflated from XML. Add only narrowly targeted rules, never package-wide keeps or global options.
```
That works because of how the SDK is built: * **No reflection, JNI or name-based serialisation.** Nothing in the SDK uses reflection. The one class it names in code, the debug activity, is kept by its manifest entry. So renaming is safe. * **Your references keep the API.** R8 keeps every Grona class and member your code calls, and removes the rest. * **XML inflation.** A `GronaSlotView` referenced from a layout your app keeps is kept with it. With AGP 8, AAPT generates that keep rule. With AGP 9’s optimised resource shrinking, which is on by default with `isShrinkResources`, AAPT keep rules are not generated and R8’s own resource analysis is expected to keep the view. Grona has not yet confirmed this on AGP 9. If it fails, use the narrow rule in the note below. Its `app:gronaPlacement` attribute is a normal resource attribute. * **R8 full mode.** Full mode has been the default since AGP 8.0, and the SDK needs no rules under it. * **Coroutines.** `kotlinx-coroutines-android` ships its own R8 rules. Do not copy them into your app. * **No global options.** The SDK sets no `-dontobfuscate`, `-dontoptimize` or similar options that would change how R8 treats the rest of your app. `grona-debug` bundles no consumer rules either. Its activity is declared in its manifest, which keeps it, and it belongs in debug builds only. ## What your app adds [Section titled “What your app adds”](#what-your-app-adds) Nothing. A standard release configuration works as it is: app/build.gradle.kts
```kotlin
android {
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
}
```
Do not add `-keep class ai.grona.**` or similar package-wide rules. They stop R8 removing unused SDK code and hide the real cause of any problem. ## Stack traces [Section titled “Stack traces”](#stack-traces) In release builds, Grona classes are obfuscated with the rest of your app. Upload your R8 mapping file to your crash reporter, as you do for your own code, to read SDK frames. The SDK never throws into your app on purpose: public calls catch their own errors and log them through your [`GronaLogger`](/mobile/android/test-and-debug/#route-logs-to-logcat). ## Next steps [Section titled “Next steps”](#next-steps) * [Google Play Data safety](/mobile/android/play-data-safety/) answers for the SDK.
# Render Grona components
> Place GronaSlotView in your layouts so Grona can show banners, progress bars, offers and messages.
This page places Grona’s own components in your screens. You add a `GronaSlotView` at each placement once, ship the release, and from then on Grona’s configuration decides what fills it, for whom and against which control group. If you want to draw the content with your own product views instead, see [Host-rendered decisions](/mobile/android/host-rendered-decisions/). A slot renders four component types: `banner`, `progress`, `offer` and `message`. For what each one looks like, see [Grona components](/mobile/concepts/components/). ## Add a slot [Section titled “Add a slot”](#add-a-slot) 1. Add a `GronaSlotView` to the layout and name its placement with `app:gronaPlacement`: res/layout/fragment_home.xml
```xml
```
2. Set the slot’s `screen` when the screen starts. Setting it starts the slot. HomeFragment.kt
```kotlin
import ai.grona.sdk.GronaScreen
import ai.grona.sdk.GronaSlotView
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.findViewById(R.id.homeTopSlot).screen = GronaScreen.HOME
}
```
3. Run the app. With nothing to show, the slot is laid out at zero height. When a campaign targets the placement, its component appears in the slot. To create a slot in code, set `placement` before `screen`, because setting `screen` is what binds the slot:
```kotlin
val slot = GronaSlotView(context).apply {
placement = "cart_top"
}
container.addView(slot, ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT))
slot.screen = GronaScreen.CART
```
## Keep the slot visible [Section titled “Keep the slot visible”](#keep-the-slot-visible) A slot with nothing to show stays `VISIBLE` at zero height, so the control arm is measured on the same view as the treatment arm. Grona logs `anchor_visible` for every arm once the slot’s top edge has been on screen for one second. * Never set a slot to `GONE` or `INVISIBLE`. A hidden slot is never measured, so only the treatment arm would be counted. Debuggable builds log a warning once per hidden slot. * Put spacing inside the slot’s parent, as padding, not as margins on the slot. Margins on an empty slot still show as a gap. * Padding or a minimum height on the slot itself does not stop it collapsing to zero height when empty. For why this matters, see [Events and exposure](/mobile/concepts/events-and-exposure/). ## Placements [Section titled “Placements”](#placements) The placement set is fixed. Use these names exactly, in XML or in code. | Placement | Where it goes | | -------------------- | ----------------------------------------------------------------------------------------------- | | `home_top` | Top of the home screen | | `home_feed` | In the home feed | | `plp_inline` | Inline in a product listing | | `pdp_below_buy` | On the product page, below the buy button | | `cart_line` | On a cart line. Keyed by the line’s product id, and the only placement for substitute decisions | | `cart_top` | Top of the cart | | `cart_below_lines` | In the cart, below the lines | | `checkout_above_pay` | On checkout, above the pay button | | `order_confirmation` | On the order confirmation screen | | `membership_top` | Top of the membership screen | A configuration that names any other placement is rejected for that campaign. For guidance on choosing placements, see [Placements](/mobile/concepts/placements/). ## Screens [Section titled “Screens”](#screens) `GronaScreen` tells Grona where the shopper is. Rules read it through `screen.type`, `screen.product`, `screen.category` and `screen.treatments`. | Value | Use it for | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `GronaScreen.HOME` | Home | | `GronaScreen.CATEGORIES` | The category list | | `GronaScreen.SEARCH` | Search results | | `GronaScreen.CART` | The cart | | `GronaScreen.CHECKOUT` | Checkout | | `GronaScreen.category(categoryId)` | One category’s product listing, for example `GronaScreen.category("dairy")` | | `GronaScreen.product(productId)` | One product page, for example `GronaScreen.product("sku_10432")` | | `GronaScreen.orderConfirmation(treatmentTypes)` | The confirmation screen. Pass the treatment types your order backend actually applied, not the ones the app showed | | `GronaScreen.other(name)` | Any other screen, by your own name, for example `GronaScreen.other("membership")` | Two screens are equal when their type and values are equal, so `GronaScreen.product("sku_10432")` set twice is the same screen. ## Screen visits [Section titled “Screen visits”](#screen-visits) Each placement on a screen counts as one visit. Grona logs `eligible`, `anchor_visible`, `exposure` and `served` at most once per visit, which keeps counts honest when views rebind. * Setting `screen` starts a visit. Setting an equal value again continues it. * A slot that leaves its window and comes back keeps its visit, however long it was away. A new slot view, such as one a fragment creates again when it returns from the back stack, joins the visit only within 2 seconds after the last slot for it left. * Setting `screen` to `null` ends the visit and empties the slot. * The slot drops its subscription with the view. * Switching a campaign off applies at once, including to content on screen. Every other change, including a campaign switched back on, waits for the next visit. A screen that stays alive, such as a tab’s root screen, keeps its visit, so a campaign that starts or is switched back on later appears there only after the visit ends. To show changes, end the visit when the screen reappears: set `screen` to `null` and back, or cancel the decision’s subscription and request again. Each new visit logs `eligible` again. To start a fresh visit on the same view, set `screen` to `null` and then to the screen again:
```kotlin
slot.screen = null
slot.screen = GronaScreen.CART
```
## Slots in a RecyclerView [Section titled “Slots in a RecyclerView”](#slots-in-a-recyclerview) A slot can sit in a RecyclerView row, such as a promotional row in a product listing. Set `placement` and `screen` in `onBindViewHolder`.
```kotlin
class PromoRowHolder(val slot: GronaSlotView) : RecyclerView.ViewHolder(slot)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val slot = GronaSlotView(parent.context).apply {
layoutParams = RecyclerView.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
}
return PromoRowHolder(slot)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is PromoRowHolder) {
holder.slot.placement = "plp_inline"
holder.slot.screen = GronaScreen.category(categoryId)
}
}
```
The visit is keyed on placement, screen and key, not on the view. Binding the same placement and screen in another holder continues the visit while any slot for it is attached, and for 2 seconds after the last one detached. So a row that rebinds across holders logs its events once. A row that comes back in a different holder more than 2 seconds after it left starts a new visit. A slot that leaves the window and comes back keeps its visit. ## Visibility and limits [Section titled “Visibility and limits”](#visibility-and-limits) * A slot counts as live while it is attached, shown and in a visible window. A slot in a stopped activity or a hidden fragment keeps its content but is not live. * Your workspace configuration caps the number of components on one screen. Lower-priority campaigns yield when the cap is reached. Only live slots count toward it. * Images come from your configuration as `asset://img/...` paths, which load from your app’s `assets/img/` folder, or as https URLs on hosts your configuration allows. Anything else is dropped and the component renders without it. ## Taps and actions [Section titled “Taps and actions”](#taps-and-actions) A banner’s call to action can add products to the cart or open a screen. The SDK runs it only from the shopper’s tap, validates it, and then calls your `GronaHost`. See [Handle actions](/mobile/android/handle-actions/). ## Next steps [Section titled “Next steps”](#next-steps) * [Handle actions](/mobile/android/handle-actions/) from Grona components. * [Match components to your app](/mobile/android/theming/). * [Render decisions with your own views](/mobile/android/host-rendered-decisions/).
# Test and debug
> Inspect SDK state in the Grona debug view, route SDK logs to Logcat and catch common integration mistakes.
This page shows what the SDK is doing on a test device. The `grona-debug` artifact adds a screen that lists the active configuration, every campaign and why it is live or not, bound slots and recent events. A logger routes SDK warnings to Logcat. The debug view works only in debuggable builds. The logger receives messages in any build you pass it to, so the example below passes it only in debuggable builds. ## Open the debug view [Section titled “Open the debug view”](#open-the-debug-view) 1. Add the debug artifact to debug builds only, as on [Install the SDK](/mobile/android/install/): app/build.gradle.kts
```kotlin
dependencies {
debugImplementation("ai.grona:grona-debug:0.1.0")
}
```
2. Call `Grona.openDebugView` from a hidden menu or a developer setting in your debug build:
```kotlin
import ai.grona.sdk.Grona
// In an activity, for example from a hidden developer menu
Grona.openDebugView(this)
```
3. Tap **Refresh** on the debug screen after you change something in the app. `openDebugView` does nothing when the app is not debuggable or the artifact is missing. In an app that is not debuggable it logs a warning. The debug screen is not exported, has no intent filters and blocks screenshots. ## What the debug view shows [Section titled “What the debug view shows”](#what-the-debug-view-shows) | Section | Contents | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | SDK | SDK version, workspace, configuration source (`bundled`, `cache` or `remote`), configuration version, key id, expiry, last configuration error, consent state, the last 4 characters of the install id, whether a shopper is identified, and whether demo mode is in effect | | Campaigns | Each campaign’s name, id and placement, whether it would render now for this shopper, and if not, why (for example `activation off`, `holdout`, `no grant` or `audience: profile unknown`). Also the assigned arm and variant | | Pins | Local pins, with a field to pin a variant on this device and a button to clear pins | | Slots | Every bound slot and decision with its placement, screen and key, and the campaign it shows | | Recent events | Up to 200 recent events, newest first, with identifiers shortened | Local pins work only in debuggable apps and never create a grant. The recent-events list is kept only in debuggable apps, so release builds keep no copy of order ids, totals or product ids for it. ## Route logs to Logcat [Section titled “Route logs to Logcat”](#route-logs-to-logcat) `GronaLogger` receives SDK diagnostics. It is silent by default. Send them to Logcat in debuggable builds: AcmeMarketApp.kt
```kotlin
package com.example.acmemarket
import ai.grona.sdk.Grona
import ai.grona.sdk.GronaLogger
import ai.grona.sdk.GronaOptions
import android.app.Application
import android.content.pm.ApplicationInfo
import android.util.Log
class AcmeMarketApp : Application() {
override fun onCreate() {
super.onCreate()
val debuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
val logger = if (debuggable) {
GronaLogger { priority, message -> Log.println(priority, "Grona", message) }
} else {
GronaLogger.NONE
}
val options = GronaOptions.Builder("acme-market")
.logger(logger)
// ... the other options from Initialize the SDK
.build()
Grona.init(this, options, AcmeGronaHost(this))
}
}
```
`priority` uses the `android.util.Log` levels: 3 debug, 4 info, 5 warning, 6 error. Messages start with `Grona:`. The SDK never passes customer ids, basket contents, tokens or keys to the logger. Your logger is called from Grona’s background threads and from the main thread, so keep it thread-safe and away from views. ## Warnings to look for [Section titled “Warnings to look for”](#warnings-to-look-for) | Warning | What to fix | | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `the anchor is GONE` or `... is INVISIBLE` | A slot or decision anchor is hidden. Keep it `VISIBLE` at zero height when empty, or the control arm is never measured. Logged once per anchor, in debuggable builds only. | | `identify ignored: customer id must match ...` | The customer id has characters outside `[A-Za-z0-9._:-]` or is longer than 128. | | `identify: an all-digit id of 9 or more characters ...` | The id looks like a phone number or national id. Pass an opaque internal id. | | `profile ignored: it is not bound to the identified customer` | `setProfile` came before `identify`, or for another customer. | | `decision() must be called on the main thread; posting it` | Call `Grona.decision` from the main thread. | | `preview link rejected: ...` | The preview link is malformed. See [Preview links](/mobile/android/preview-links/#link-format). | | `not the main process; Grona stays inactive` | `Grona.init` ran in a secondary process. | ## Check the integration [Section titled “Check the integration”](#check-the-integration) * **Empty slots.** In the layout inspector, an empty slot is `VISIBLE` with zero height. No hidden-anchor warning appears. * **Campaign state.** Each campaign you expect is live in the debug view, or shows a reason you understand. * **Basket.** Progress bars and substitutes follow the cart as you change it. If not, a cart path is missing `setBasket`. * **Consent.** Under `UNKNOWN` no events are sent. After `GRANTED`, events reach your events endpoint. * **Release build.** `openDebugView` does nothing, and your [R8 build](/mobile/android/r8-and-proguard/) runs without extra rules. To pick up a configuration change during a test without waiting, call `Grona.refresh` with a listener. See [Refresh on demand](/mobile/android/initialize/#refresh-on-demand). To see one variant, use a [preview link](/mobile/android/preview-links/) or a local pin. Caution `GronaDebugBridge` and the debug snapshot types ship in `grona-core`, in the package `ai.grona.sdk.debug`, for the `grona-debug` artifact only. They need an opt-in that fails the build by default and may change in any release. Do not call them from your app.
# Theming
> Set the fonts, colours and corner radius Grona components use so they match the rest of your app.
This page matches Grona’s components to your app. You pass a `GronaTheme` in your options once, at launch. Every banner, progress bar, offer and message then uses your fonts, colours and corner radius. Decisions you render yourself are not affected, because your own views draw them. ## Set a theme [Section titled “Set a theme”](#set-a-theme) AcmeMarketApp.kt
```kotlin
import ai.grona.sdk.GronaOptions
import ai.grona.sdk.GronaTheme
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
val theme = GronaTheme.Builder()
.fonts(
ResourcesCompat.getFont(this, R.font.acme_sans_regular),
ResourcesCompat.getFont(this, R.font.acme_sans_bold),
)
.primary(ContextCompat.getColor(this, R.color.acme_primary))
.onPrimary(ContextCompat.getColor(this, R.color.acme_on_primary))
.accent(ContextCompat.getColor(this, R.color.acme_accent))
.cornerRadiusDp(12f)
.build()
val options = GronaOptions.Builder("acme-market")
.theme(theme)
// ...
.build()
```
Colours are ARGB `Int` values, as `ContextCompat.getColor` returns them. ## Theme tokens [Section titled “Theme tokens”](#theme-tokens) | Builder method | Default | Used for | | ------------------------ | -------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `fonts(regular, bold)` | `null`, `null` | Component text. A `null` regular font uses the platform default; a `null` bold font uses the bold style of the regular font. | | `primary(color)` | `0xFF054B3A` | Banner background, the progress bar’s fill before a tier is met, and icon colour in messages | | `onPrimary(color)` | `0xFFFFFFFF` | Text on banners | | `accent(color)` | `0xFF6FCF97` | Banner accents, the progress bar’s fill once a tier is met, and the tint of offers and message icons | | `surface(color)` | `0xFFFFFFFF` | Background of progress bars, and the base of offer and message backgrounds | | `text(color)` | `0xFF1A1C1A` | Body text on progress bars, offers and messages | | `mutedText(color)` | `0xFF6B706C` | Secondary text, such as footnotes | | `cornerRadiusDp(radius)` | `14` | Corner radius of component cards, in dp. Values are kept between 0 and 32. | ## How campaign styles combine with your theme [Section titled “How campaign styles combine with your theme”](#how-campaign-styles-combine-with-your-theme) A campaign can set its own `background`, `text` and `accent` colours for one component, for example a weekend breakfast banner in a seasonal colour. Those override your theme for that component only. Everything else comes from your theme. Whatever the source, the SDK checks text against its background. When the preferred text colour does not reach WCAG AA contrast on that background, the SDK uses white or near-black text, whichever reads better. ## Limits [Section titled “Limits”](#limits) * The theme is fixed at `Grona.init`. There is no call to change it at runtime, so a theme switch while the app runs, such as a change to dark mode, does not reach Grona components until the app process starts again. * Components use Android Views, not Jetpack Compose. Compose theming does not reach them. ## Next steps [Section titled “Next steps”](#next-steps) * [Test and debug](/mobile/android/test-and-debug/) your integration.
# Track events
> Forward view, cart, checkout and purchase events from your analytics layer so Grona can measure campaigns.
This page sends commerce events to Grona. Grona logs its own campaign events, such as exposures and taps, but it needs your app to report what the shopper did next: products viewed, cart changes, checkout and purchase. Forward them from the same analytics layer that feeds your other tools. ## The events [Section titled “The events”](#the-events) Build each event with a `GronaEvent` factory and pass it to `Grona.track`. Money is in minor units, so `349` is 3.49. | Factory | Send it when | | ------------------------------------------------------------------ | ----------------------------------------------------------------------------- | | `GronaEvent.viewItem(productId, priceMinor)` | A product page opens. | | `GronaEvent.addToCart(productId, qty, priceMinor)` | A quantity of a product is added to the cart. `priceMinor` is the unit price. | | `GronaEvent.removeFromCart(productId, qty)` | A quantity of a product is removed from the cart. | | `GronaEvent.beginCheckout(totalMinor)` | The shopper starts checkout. | | `GronaEvent.purchase(orderId, totalMinor, productIds, treatments)` | Your order backend has confirmed an order. | `Grona.track` is safe from any thread. 1. Send `viewItem` from your product screen:
```kotlin
import ai.grona.sdk.Grona
import ai.grona.sdk.GronaEvent
Grona.track(GronaEvent.viewItem("sku_10432", 349))
```
2. Send `addToCart` and `removeFromCart` from your cart store’s change listener, once per product whose quantity moved. That covers changes from Grona actions and from your own screens alike.
```kotlin
fun onCartChanged(before: AcmeCart, after: AcmeCart) {
val ids = (before.lines.map { it.productId } + after.lines.map { it.productId }).toSet()
for (id in ids) {
val delta = after.qty(id) - before.qty(id)
when {
delta > 0 -> Grona.track(GronaEvent.addToCart(id, delta, after.unitPriceMinor(id)))
delta < 0 -> Grona.track(GronaEvent.removeFromCart(id, -delta))
}
}
}
```
3. Send `beginCheckout` when checkout opens:
```kotlin
Grona.track(GronaEvent.beginCheckout(cart.totalMinor)) // 897 is 8.97
```
4. Send `purchase` after your order backend answers. Pass every product id in the order, and only the treatments the backend applied:
```kotlin
Grona.track(
GronaEvent.purchase(
orderId = order.id,
totalMinor = order.totalMinor,
productIds = order.lines.map { it.productId },
treatments = appliedTreatments, // from Grona.activeTreatments(), filtered to what the backend applied
),
)
```
For how to collect the treatments at checkout, see [Offers and grants](/mobile/android/offers-and-grants/#send-treatments-with-the-order). ## What leaves the device [Section titled “What leaves the device”](#what-leaves-the-device) The SDK minimises what it sends. You pass full data and it strips what Grona does not need. * **Session products.** A session product is one that a Grona campaign showed or added in this session. `viewItem`, `addToCart` and `removeFromCart` are always counted, but they carry the product id and price only for session products. For any other product those fields are left out. * **Purchases.** `purchase` keeps the order id, the total, the currency of your latest basket snapshot, the treatments and only the session products in the order. The full basket never leaves the device. * **Exception: keyed placements.** On keyed placements such as `cart_line`, campaign events carry the line’s product id as their `key`, in every arm. So while a `cart_line` campaign is live, the product ids of the cart lines bound to it leave the device. * **Never sent.** Search text, names, phone numbers, emails and browsing history. Pass only an opaque customer id to [`identify`](/mobile/android/identify-shoppers/). Caution Client purchase events can be spoofed, so Grona uses them only to join exposure to orders. Report revenue from your order system, server to server. See [Report revenue](/mobile/backend/revenue/). ## Events Grona logs itself [Section titled “Events Grona logs itself”](#events-grona-logs-itself) Do not send these. The SDK logs them for every placement it serves. | Event | When | | ---------------- | ------------------------------------------------------------------------------ | | `eligible` | A decision is computed for a placement, once per visit, for every arm | | `anchor_visible` | The slot or anchor’s top edge has been on screen for one second, for every arm | | `served` | A treatment first reaches its slot or listener | | `exposure` | Half the content has been visible for one second (diagnostic) | | `action` | An add, add-all, replace or navigate from a tap or your call on a decision | | `dismiss` | A dismiss | For how these are analysed, see [Events and exposure](/mobile/concepts/events-and-exposure/). ## Delivery [Section titled “Delivery”](#delivery) Events are held or sent according to the shopper’s [consent](/mobile/android/consent/). Once consent is granted: * The SDK sends events while the app is in the foreground every 30 seconds, once 20 are queued, and when the app goes to the background. * A batch holds at most 50 events and is gzipped and sent over https. * The queue on disk holds at most 1,000 events or 7 days of events. The oldest go first. * A failed batch is retried at most 3 times with backoff. Without an `eventsUrl` in your options, the SDK sends no events and keeps no event queue. Debuggable builds keep the last 200 events in memory for the [debug view](/mobile/android/test-and-debug/). ## Next steps [Section titled “Next steps”](#next-steps) * [Set consent](/mobile/android/consent/). * [Test and debug](/mobile/android/test-and-debug/) your events.
# Grants and order verification
> Fetch grants for a shopper and verify them when an order is placed.
Your backend has two jobs for offers that carry money. It fetches grants for the signed-in shopper so the app can show the offer, and it verifies those grants again when the shopper places an order, because the app cannot be trusted with money. This page gives the procedure and complete samples in Node.js and Python. If grants are new to you, read [Offers and grants](/mobile/concepts/offers-and-grants/) first. ## How a grant reaches an order [Section titled “How a grant reaches an order”](#how-a-grant-reaches-an-order) 1. Your app calls `identify` with the shopper’s opaque customer id, for example `cus_7Hq2Lm9x`. 2. The SDK calls your app’s grant source, and your grant source calls your backend inside the shopper’s authenticated session. 3. Your backend fetches the shopper’s grants from Grona, server to server, and returns them to the app unchanged. 4. The SDK verifies each grant. It shows an offer, for example a weekend offer of 10% off the order, only when a grant matches it. 5. At checkout your app reads the SDK’s active treatments and sends each grant token with the order request. 6. Your order backend verifies every token, re-checks the campaign’s conditions against the order it priced, records each grant as used inside the order transaction, and applies the treatment from the verified claims. ## Serve the grant source [Section titled “Serve the grant source”](#serve-the-grant-source) The SDK calls your app’s grant source when the shopper is identified and again shortly before the nearest grant expires. Your app passes the call to an endpoint on your backend, and that endpoint does four things: * It authenticates the request with the shopper’s session and takes the customer id from the session, never from the request. Otherwise anyone could read another shopper’s offers. * It calls `POST https://api.grona.ai/v1/mobile/grants` server to server with your workspace secret key. The [Mobile grants API](/api/mobile-grants/) page describes the request and the response, and [Authentication](/api/authentication/) describes the key. * It returns the list of compact tokens to the app as Grona sent them. * It answers quickly. The SDK waits at most 10 seconds for the grant source, treats a timeout or a failure as no answer, and tries again later with backoff. The grants endpoint is available on request, like the mobile SDKs. Caution Your workspace secret key stays on your servers. Never put it in the app. The workspace key the app carries is a public identifier; the secret key is not. ## What your order backend checks [Section titled “What your order backend checks”](#what-your-order-backend-checks) Run every check on every token. A failure means no treatment for that grant, and the order still succeeds. | Check | Rule | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Size | The token is at most 4,096 characters | | Header | Exactly `alg`, `kid` and `typ`, with `alg` equal to `ES256` and `typ` equal to `grona-grant+jwt`. This rejects `none`, `HS256` and every other algorithm | | Key | `kid` names a grant key you pinned. Unknown key ids are rejected | | Signature | ES256 with that key | | Customer | `sub` equals the customer of the authenticated order. A guest order gets no treatment | | Workspace | `wks` equals your workspace key, for example `acme-market` | | Preview | `prv` is `false` | | Ids and value | `cmp`, `var`, `trt` and `jti` match `[A-Za-z0-9._:-]{1,128}`, and `val` is an integer of 0 or more | | Time | `iat` and `exp` are Unix seconds between 2020 and 2100, `iat` is not in the future, `exp` has not passed, and `exp` is after `iat` and at most 24 hours later | | Basket | Your rules for the campaign hold for the order your server priced, for example a minimum basket total. Use your own totals, never totals the app sent | | Single use | No earlier order has redeemed this `jti` | Then apply the treatment from the verified claims only: `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. The samples allow 30 seconds of clock leeway on `iat` and `exp`. Grants carry no `nbf` claim, but both libraries reject a token whose `nbf` is in the future. ## Install the libraries [Section titled “Install the libraries”](#install-the-libraries) * Node.js
```sh
npm install --save-exact jose@6.2.12 pg@8.23.1
```
* Python
```sh
pip install PyJWT==2.15.1 cryptography==50.0.2 "psycopg[binary]==3.3.6"
```
The samples were tested with these exact versions on Node.js 20, 22, 24 and 26 and on Python 3.13, against PostgreSQL 17. ## Create the redemption table [Section titled “Create the redemption table”](#create-the-redemption-table) grona_grant_redemptions.sql
```sql
CREATE TABLE grona_grant_redemptions (
jti text PRIMARY KEY,
order_id text NOT NULL,
customer_id text NOT NULL,
campaign_id text NOT NULL,
variant_id text NOT NULL,
treatment_type text NOT NULL,
value_milli bigint NOT NULL,
expires_at timestamptz NOT NULL,
redeemed_at timestamptz NOT NULL DEFAULT now()
);
```
The primary key on `jti` makes single use atomic. Keep the rows, because they are also your record for [revenue reporting](/mobile/backend/revenue/). If you prune them, delete only rows whose `expires_at` has passed by more than your clock leeway, since verification rejects those grants anyway. ## Verify and redeem a grant [Section titled “Verify and redeem a grant”](#verify-and-redeem-a-grant) Give your server the grant public keys from Grona as JSON in the `GRONA_GRANT_KEYS` environment variable, keyed by `kid`. Pin the current key and the next one:
```sh
GRONA_GRANT_KEYS='{"acme-market-grants-2026-10":"","acme-market-grants-2027-04":""}'
```
Each value is a P-256 public key as base64 DER SubjectPublicKeyInfo, the same format your app pins. The module refuses to load a key that is not P-256. * Node.js grona-grants.mjs
```js
import { createPublicKey } from 'node:crypto';
import { jwtVerify } from 'jose';
const WORKSPACE = 'acme-market';
const GRANT_TYP = 'grona-grant+jwt';
const LEEWAY_SECONDS = 30;
const MIN_TIME = 1577836800; // 2020-01-01T00:00:00Z
const MAX_TIME = 4102444800; // 2100-01-01T00:00:00Z
const MAX_LIFETIME_SECONDS = 24 * 60 * 60;
const MAX_TOKEN_LENGTH = 4096;
const ID = /^[A-Za-z0-9._:-]{1,128}$/;
// Grant public keys from Grona, pinned by kid: {"": ""}.
const GRANT_KEYS = new Map(
Object.entries(JSON.parse(process.env.GRONA_GRANT_KEYS)).map(([kid, spki]) => {
const key = createPublicKey({ key: Buffer.from(spki, 'base64'), format: 'der', type: 'spki' });
if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails.namedCurve !== 'prime256v1') {
throw new Error(`Grona grant key ${kid} is not a P-256 public key`);
}
return [kid, key];
}),
);
export class GrantRejected extends Error {}
const reject = (reason) => {
throw new GrantRejected(reason);
};
const isId = (value) => typeof value === 'string' && ID.test(value);
const isTime = (value) => Number.isSafeInteger(value) && value >= MIN_TIME && value <= MAX_TIME;
// Returns the verified claims or throws GrantRejected. customerId is the customer of the authenticated order.
export async function verifyGrant(token, customerId) {
if (typeof token !== 'string' || token.length > MAX_TOKEN_LENGTH) reject('token size');
let claims;
try {
({ payload: claims } = await jwtVerify(
token,
(header) => {
const fields = Object.keys(header).sort().join(',');
if (fields !== 'alg,kid,typ' || header.alg !== 'ES256' || header.typ !== GRANT_TYP) reject('header');
const key = typeof header.kid === 'string' ? GRANT_KEYS.get(header.kid) : undefined;
return key ?? reject('unknown kid');
},
{ algorithms: ['ES256'], clockTolerance: LEEWAY_SECONDS },
));
} catch (err) {
if (err instanceof GrantRejected) throw err;
reject(`jws: ${err.code ?? err.message}`);
}
const now = Math.floor(Date.now() / 1000);
if (!isId(customerId) || claims.sub !== customerId) reject('sub');
if (claims.wks !== WORKSPACE) reject('wks');
if (claims.prv !== false) reject('prv');
for (const name of ['cmp', 'var', 'trt', 'jti']) if (!isId(claims[name])) reject(name);
if (!Number.isSafeInteger(claims.val) || claims.val < 0) reject('val');
if (!isTime(claims.iat) || !isTime(claims.exp)) reject('iat or exp');
if (claims.iat > now + LEEWAY_SECONDS) reject('iat in the future');
if (claims.exp <= now - LEEWAY_SECONDS) reject('expired');
if (claims.exp <= claims.iat || claims.exp - claims.iat > MAX_LIFETIME_SECONDS) reject('lifetime');
return claims;
}
// Records the grant as used by this order, inside the order transaction. False means another order used it.
export async function redeemGrant(tx, claims, orderId) {
const result = await tx.query(
`INSERT INTO grona_grant_redemptions
(jti, order_id, customer_id, campaign_id, variant_id, treatment_type, value_milli, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, to_timestamp($8))
ON CONFLICT (jti) DO NOTHING`,
[claims.jti, orderId, claims.sub, claims.cmp, claims.var, claims.trt, claims.val, claims.exp],
);
return result.rowCount === 1;
}
```
* Python grona_grants.py
```python
import base64
import json
import os
import re
import time
import jwt
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.serialization import load_der_public_key
WORKSPACE = "acme-market"
GRANT_TYP = "grona-grant+jwt"
LEEWAY_SECONDS = 30
MIN_TIME = 1577836800 # 2020-01-01T00:00:00Z
MAX_TIME = 4102444800 # 2100-01-01T00:00:00Z
MAX_LIFETIME_SECONDS = 24 * 60 * 60
MAX_TOKEN_LENGTH = 4096
ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
def _load_keys(config: str) -> dict:
keys = {}
for kid, spki in json.loads(config).items():
key = load_der_public_key(base64.b64decode(spki, validate=True))
if not isinstance(key, ec.EllipticCurvePublicKey) or key.curve.name != "secp256r1":
raise ValueError(f"Grona grant key {kid} is not a P-256 public key")
keys[kid] = key
return keys
# Grant public keys from Grona, pinned by kid: {"": ""}.
GRANT_KEYS = _load_keys(os.environ["GRONA_GRANT_KEYS"])
class GrantRejected(Exception):
pass
def _is_id(value) -> bool:
return isinstance(value, str) and ID.fullmatch(value) is not None
def _is_time(value) -> bool:
return type(value) is int and MIN_TIME <= value <= MAX_TIME
def verify_grant(token: str, customer_id: str) -> dict:
"""Return the verified claims or raise GrantRejected. customer_id is the customer of the authenticated order."""
if not isinstance(token, str) or len(token) > MAX_TOKEN_LENGTH:
raise GrantRejected("token size")
try:
header = jwt.get_unverified_header(token)
except jwt.PyJWTError as err:
raise GrantRejected(f"header: {err}") from None
if set(header) != {"alg", "kid", "typ"} or header["alg"] != "ES256" or header["typ"] != GRANT_TYP:
raise GrantRejected("header")
key = GRANT_KEYS.get(header["kid"]) if isinstance(header["kid"], str) else None
if key is None:
raise GrantRejected("unknown kid")
try:
claims = jwt.decode(token, key, algorithms=["ES256"], leeway=LEEWAY_SECONDS)
except jwt.PyJWTError as err:
raise GrantRejected(f"jws: {err}") from None
now = int(time.time())
if not _is_id(customer_id) or claims.get("sub") != customer_id:
raise GrantRejected("sub")
if claims.get("wks") != WORKSPACE:
raise GrantRejected("wks")
if claims.get("prv") is not False:
raise GrantRejected("prv")
for name in ("cmp", "var", "trt", "jti"):
if not _is_id(claims.get(name)):
raise GrantRejected(name)
value = claims.get("val")
if type(value) is not int or value < 0:
raise GrantRejected("val")
iat, exp = claims.get("iat"), claims.get("exp")
if not (_is_time(iat) and _is_time(exp)):
raise GrantRejected("iat or exp")
if iat > now + LEEWAY_SECONDS:
raise GrantRejected("iat in the future")
if exp <= now - LEEWAY_SECONDS:
raise GrantRejected("expired")
if exp <= iat or exp - iat > MAX_LIFETIME_SECONDS:
raise GrantRejected("lifetime")
return claims
def redeem_grant(conn, claims: dict, order_id: str) -> bool:
"""Record the grant as used by this order, inside the order transaction. False means another order used it."""
cur = conn.execute(
"""
INSERT INTO grona_grant_redemptions
(jti, order_id, customer_id, campaign_id, variant_id, treatment_type, value_milli, expires_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, to_timestamp(%s))
ON CONFLICT (jti) DO NOTHING
""",
(claims["jti"], order_id, claims["sub"], claims["cmp"], claims["var"], claims["trt"], claims["val"], claims["exp"]),
)
return cur.rowcount == 1
```
## Call it from your order handler [Section titled “Call it from your order handler”](#call-it-from-your-order-handler) Run the checks inside the transaction that writes the order. If the order fails and rolls back, the redemption rolls back with it, so the shopper can use the grant when they try again. * Node.js checkout.mjs
```js
import { GrantRejected, redeemGrant, verifyGrant } from './grona-grants.mjs';
// Your own rules for each Grona campaign, checked against the order as your server priced it.
const CAMPAIGNS = new Map([['weekend-offer', { treatment: 'percent_off', valueMilli: 10000, minTotalMinor: 4000 }]]);
function qualifies(claims, order) {
const campaign = CAMPAIGNS.get(claims.cmp);
return (
campaign !== undefined &&
claims.trt === campaign.treatment &&
claims.val === campaign.valueMilli &&
order.totalMinor >= campaign.minTotalMinor
);
}
// pool is your pg.Pool. customerId comes from the authenticated session, never from the request body.
// grantTokens are the tokens the app sent with the order. Returns the claims of each treatment to apply.
export async function placeOrder(pool, customerId, order, grantTokens) {
const tokens = Array.isArray(grantTokens) ? grantTokens.slice(0, 50) : [];
const applied = [];
const tx = await pool.connect();
try {
await tx.query('BEGIN');
await tx.query('INSERT INTO orders (id, customer_id, total_minor, currency) VALUES ($1, $2, $3, $4)', [
order.id,
customerId,
order.totalMinor,
order.currency,
]);
for (const token of tokens) {
let claims;
try {
claims = await verifyGrant(token, customerId);
} catch (err) {
if (!(err instanceof GrantRejected)) throw err;
console.warn(`Grona grant not applied: ${err.message}`);
continue;
}
if (applied.some((done) => done.cmp === claims.cmp) || !qualifies(claims, order)) continue;
if (!(await redeemGrant(tx, claims, order.id))) continue; // already used by another order
applied.push(claims);
}
// Apply each treatment here, inside the transaction, from claims.trt and claims.val only.
await tx.query('COMMIT');
return applied;
} catch (err) {
await tx.query('ROLLBACK');
throw err;
} finally {
tx.release();
}
}
```
* Python checkout.py
```python
import logging
from grona_grants import GrantRejected, redeem_grant, verify_grant
log = logging.getLogger(__name__)
# Your own rules for each Grona campaign, checked against the order as your server priced it.
CAMPAIGNS = {"weekend-offer": {"treatment": "percent_off", "value_milli": 10000, "min_total_minor": 4000}}
def qualifies(claims: dict, order: dict) -> bool:
campaign = CAMPAIGNS.get(claims["cmp"])
return (
campaign is not None
and claims["trt"] == campaign["treatment"]
and claims["val"] == campaign["value_milli"]
and order["total_minor"] >= campaign["min_total_minor"]
)
def place_order(conn, customer_id: str, order: dict, grant_tokens: list) -> list:
"""conn is your psycopg connection. customer_id comes from the authenticated session, never from the request body.
grant_tokens are the tokens the app sent with the order. Returns the claims of each treatment to apply."""
tokens = grant_tokens[:50] if isinstance(grant_tokens, list) else []
applied = []
with conn.transaction():
conn.execute(
"INSERT INTO orders (id, customer_id, total_minor, currency) VALUES (%s, %s, %s, %s)",
(order["id"], customer_id, order["total_minor"], order["currency"]),
)
for token in tokens:
try:
claims = verify_grant(token, customer_id)
except GrantRejected as err:
log.warning("Grona grant not applied: %s", err)
continue
if any(done["cmp"] == claims["cmp"] for done in applied) or not qualifies(claims, order):
continue
if not redeem_grant(conn, claims, order["id"]):
continue # already used by another order
applied.append(claims)
# Apply each treatment here, inside the transaction, from claims["trt"] and claims["val"] only.
return applied
```
The handler applies a grant only when its treatment type and value match the campaign you expect, so a grant with any other value is not applied. It applies at most one grant per campaign to an order, and a second grant for the same campaign stays unspent. The SDK holds at most 50 grants, so the handler looks at no more than 50 tokens. Replace the `INSERT INTO orders` statement with your own order write, and keep the `CAMPAIGNS` rules in step with the campaigns you run. Apply the treatment from `trt` and `val`. For `percent_off`, `val` is in thousandths of a percentage point, so the discount is the order total in minor units times `val`, divided by 100,000, rounded down and capped at the order total. For this example’s `10000`, a 52.00 order (`5200`) gets 5.20 (`520`) off. In Python, `conn.transaction()` begins a transaction and commits it at the end of the block when the connection is idle. On a connection that is already inside a transaction, it becomes a savepoint instead, and nothing commits until your code commits the outer transaction. Rejections are logged with a reason, never with the token. Count them by reason: a rise in signature or key failures is worth investigating. ## Use each grant once [Section titled “Use each grant once”](#use-each-grant-once) ### Key single use on `jti`, never on the token string [Section titled “Key single use on jti, never on the token string”](#key-single-use-on-jti-never-on-the-token-string) ECDSA signatures are malleable. For any valid signature `(r, s)`, the pair `(r, n - s)` is also valid, where `n` is the order of the P-256 curve, and anyone holding a token can compute it without the private key. So one grant has at least two valid token strings. Some JWT libraries also accept base64url padding on the signature segment, which gives the same grant yet another string. A backend that remembered used token strings would apply the same grant more than once. Keying on `jti` covers every form. ### Make the insert atomic [Section titled “Make the insert atomic”](#make-the-insert-atomic) * **SQL.** Insert the `jti` under a unique constraint, as the samples do with `INSERT ... ON CONFLICT (jti) DO NOTHING`, and treat zero inserted rows as already used. Two orders racing with one grant get exactly one treatment. In PostgreSQL a plain `INSERT` that hits the constraint aborts the whole transaction and the order with it, which is why the samples use `ON CONFLICT DO NOTHING`. Under `REPEATABLE READ` or `SERIALIZABLE` isolation, retry the order on a serialization failure, as for any write conflict. * **Redis.** Run `SET grona:grant: NX EXAT ` before you commit the discount, and treat a nil reply as already used. Expire the key at `exp` plus your clock leeway, not at `exp`, or the grant could be used again inside the leeway. `EXAT` needs Redis 6.2 or later. If the order fails after the key is set, the grant stays spent, which fails closed. ## Test your verifier [Section titled “Test your verifier”](#test-your-verifier) Before you go live, run your verifier against these cases. The samples on this page pass all of them. * A valid grant is accepted, and treated once. * The same grant on a second order: the order succeeds with no treatment. * The same grant with its signature changed to `(r, n - s)` passes verification, then fails single use. * Two concurrent orders with one grant: exactly one is treated. * A payload changed after signing is rejected. * A `typ` other than `grona-grant+jwt`, including the same value in another case, is rejected. * `alg` set to `none`, and `HS256` signed with your public key as the secret, are rejected. * An unknown `kid`, and a known `kid` with a signature from another key, are rejected. * An extra header field such as `jku` is rejected. * An expired grant, an `iat` in the future, and a lifetime over 24 hours are rejected. * Another shopper’s `sub`, another workspace’s `wks`, and `prv` set to `true` are rejected. * An order below the campaign’s minimum gets no treatment and leaves the grant unspent. * A grant whose value differs from the campaign’s value gets no treatment and stays unspent. ## Next steps [Section titled “Next steps”](#next-steps) [Report revenue](/mobile/backend/revenue/)Send order data from your order system and join it to campaign events. [Rotate signing keys](/mobile/backend/key-rotation/)Add the next grant key before Grona signs with it. [Mobile grants API](/api/mobile-grants/)The request your backend sends to fetch a shopper's grants.
# Rotate signing keys
> Rotate configuration and grant signing keys without breaking installed apps.
Grona signs two things that your app and your servers verify: the configuration and grants. Each has its own key pair, and both rotate the same way. The next public key ships in every verifier before Grona signs with it, and verifiers pick a key by its `kid`. This page gives the procedure, the bounds Grona signs within, and what to do if a key is exposed. ## The two keys [Section titled “The two keys”](#the-two-keys) | Key | Signs | Pinned in | | ---------- | ------------------------------- | --------------------------------------------- | | Config key | Configurations and preview pins | Your app’s SDK options | | Grant key | Grants | Your app’s SDK options and your order backend | Each public key is a P-256 key as base64 DER SubjectPublicKeyInfo, with a key id such as `acme-market-2026-10` for a config key or `acme-market-grants-2026-10` for a grant key. Grona holds the private keys. It does not publish public keys at a URL, so there is no key set to download: Grona gives you the keys, and you pin them. Keys reach the SDK only through your app’s options. A configuration cannot add or remove keys, so a key leaves an installed app only with an app release. ## How verifiers pick a key [Section titled “How verifiers pick a key”](#how-verifiers-pick-a-key) Every configuration and every token names its key in the `kid` header field. The SDK and your order backend look the `kid` up in the keys they pin and reject any `kid` they do not know. Pinning two keys, the current one and the next one, is what lets Grona switch keys without breaking installed apps. To set the keys in your app, see Initialise the SDK for [Android](/mobile/android/initialize/) or [iOS](/mobile/ios/initialize/). For your order backend, see [Grants and order verification](/mobile/backend/grants-and-orders/). ## Rotate a key [Section titled “Rotate a key”](#rotate-a-key) 1. Grona gives you the next public key and its `kid`, for example `acme-market-2027-04`. 2. Pin it as the next key, beside the current one. Pin a config key in your app. Pin a grant key in your app and in your order backend. 3. Deploy your backend, and release the app version that carries the next key. 4. Once that release has reached the installs you need, agree the switch with Grona. Grona then signs with the next key. 5. For a grant key, keep the old key in your order backend until every grant signed with it has expired. That is at most 24 hours after the switch, plus your clock leeway. Then remove it. 6. In your next app release, drop the old key and pin a new next key, so the following rotation is ready in advance. ### Installs without the new key [Section titled “Installs without the new key”](#installs-without-the-new-key) An install that never received the next key fails safe for the app and closed for money: * **Config key.** The SDK rejects configurations signed with the new key. It keeps the last configuration it accepted until that one expires, and then renders nothing. It also ignores preview pins signed with the new key. * **Grant key.** The SDK rejects grants signed with the new key, so offers do not render and campaigns leave their placements to the next campaign. The shopper’s orders still succeed. ## Bounds Grona signs within [Section titled “Bounds Grona signs within”](#bounds-grona-signs-within) Grona signs within these rules. Many of them mirror checks the SDK runs, which would reject the configuration or leave its campaigns inactive: * `issued_at` falls between 2020-01-01 and 2100-01-01, and `expires_at` is after `issued_at` and at most 400 days later. Remote configurations expire in days, not months. * `ttl_seconds` is between 300 and 86,400, and 300 for configurations that carry money treatments. * `schema` is 1, and `version` is a positive integer that rises for each workspace and is never reused. * `version` is never a timestamp or another large number. The rollback check on each install is permanent, so one mistakenly high version would block every later configuration until the server’s versions passed it. * A bundled configuration never carries a version above the one the server serves. * A `304 Not Modified` does not extend `expires_at`, so Grona signs and serves a new version before the current one expires. Otherwise every device would freeze. * Grona never raises the top-level `min_sdk` or `schema` while older SDKs are in the field. It sets `min_sdk` on individual campaigns instead. * A configuration holds at most 200 campaigns, with unique ids, known placements, a unit of `install` or `customer`, variant weights that sum to 10,000, holdouts between 0 and 10,000 basis points, and both salts set. A campaign with a money treatment uses the `customer` unit. * `settings.demo` is never `true` in a production configuration. * Grants and preview pins last at most 24 hours. ## If a key is exposed [Section titled “If a key is exposed”](#if-a-key-is-exposed) Contact Grona at once through [Get help](/get-started/support/). What you do next depends on the key. ### Grant key [Section titled “Grant key”](#grant-key) Remove its `kid` from your order backend’s keys. From then on, grants signed with it fail verification: no treatment is applied, and orders still succeed. Your order backend is where money is contained, so this step comes first. Installed apps trust the key until an app release removes it. A forged grant could make an offer render on a device, but your backend never honours it. Pin the replacement key that Grona gives you in your backend and in your next app release. ### Config key [Section titled “Config key”](#config-key) Installed apps trust every config key they pin until an app release removes it. A configuration signed with an exposed key passes the signature check on those installs. Such a configuration: * could change which campaigns show and what they say, within the limits the SDK applies to every configuration: the add-to-basket caps, typed routes only, plain text only, and prices and product names from your app’s own data; * could not create a grant, because grants need the separate grant key, so it could not make your backend apply a treatment; * could sign preview pins, which use the config key; * with an inflated `version`, would hold back legitimate configurations on every install that accepted it, because the rollback check is permanent. Ship an app release that pins the replacement key and drops the exposed one. There is no supported way yet to recover installs that accepted a forged configuration. Contact Grona through [Get help](/get-started/support/). Caution Never put a private key in your app, your repository or your order backend. Your verifiers need only public keys. ## Next steps [Section titled “Next steps”](#next-steps) [Signed configuration](/mobile/concepts/signed-configuration/)How the SDK verifies the configuration with the keys you pin. [Grants and order verification](/mobile/backend/grants-and-orders/)Configure grant keys in your order backend and verify grants.
# Backend integration overview
> The jobs your servers do in a Grona mobile integration.
Most Grona content runs in your app on the signed configuration alone. Offers that carry money are different. The app cannot prove who may receive an offer or what an order was worth, so your servers do that work. This page lists the four jobs your backend does in a Grona mobile integration and shows how they fit together. ## The jobs [Section titled “The jobs”](#the-jobs) | Job | When | What your server does | | ------------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Serve the grant source | When a shopper signs in, and again before their grants expire | Inside the shopper’s authenticated session, fetches their grants from Grona server to server and returns them to the app unchanged | | Verify and redeem grants | When an order is placed | Verifies every grant token the app sent, re-checks the campaign’s conditions against the real order, uses each grant once, and applies the value from the verified claims | | Report revenue | After orders | Sends order data from your order system, which is the revenue truth for Grona’s analysis | | Manage keys | Before each key rotation | Pins Grona’s grant public keys by `kid`, and adds the next key before Grona signs with it | If your app shows no offers, you need only the revenue job, so that campaigns are measured against real orders. ## How the jobs fit together [Section titled “How the jobs fit together”](#how-the-jobs-fit-together) 1. The shopper signs in, and your app calls `identify` with their opaque customer id, for example `cus_7Hq2Lm9x`. 2. The SDK calls your app’s grant source, which calls your backend. Your backend calls Grona’s grants endpoint with your workspace secret key and returns the grants to the app. See [Grants and order verification](/mobile/backend/grants-and-orders/). 3. The SDK verifies the grants with the grant keys your app pins and renders the offers they match, for example a weekend offer of 10% off the order. 4. At checkout, your app sends the grant tokens of the active treatments with the order. 5. Your order backend verifies each token, records each grant as used inside the order transaction, and applies the treatment from the verified claims. A failed check means no treatment, and the order still succeeds. 6. Your order system reports the order to Grona. Grona joins it to the app’s events on the order id and the grant’s `jti`. See [Report revenue](/mobile/backend/revenue/). ## What stays with Grona [Section titled “What stays with Grona”](#what-stays-with-grona) * **The configuration.** Grona signs it and publishes it at your config URL. Your servers neither host nor sign it. * **Eligibility.** Grona’s server decides which shopper gets which grant. * **Private keys.** Grona holds the private keys that sign configurations, preview pins and grants. You receive only public keys. Grona does not publish them at a URL: it gives them to you, and you pin them. See [Rotate signing keys](/mobile/backend/key-rotation/). * **Single use.** There is no Grona endpoint that redeems a grant. Your order database enforces single use on `jti`. ## Keys and secrets [Section titled “Keys and secrets”](#keys-and-secrets) | Credential | Where it lives | Secret | | ---------------------------------------- | ------------------------------------- | ----------------------------- | | Workspace key, for example `acme-market` | In your app | No. It is a public identifier | | Config public keys | In your app | No | | Grant public keys | In your app and in your order backend | No | | Workspace secret key | On your servers only | Yes. Never ship it in the app | See [Keys and environments](/get-started/keys-and-environments/) and [Authentication](/api/authentication/). ## Next steps [Section titled “Next steps”](#next-steps) [Grants and order verification](/mobile/backend/grants-and-orders/)Fetch grants for a shopper and verify them when an order is placed, with tested samples. [Report revenue](/mobile/backend/revenue/)Why revenue comes from your order system and how it joins to campaign events. [Rotate signing keys](/mobile/backend/key-rotation/)Rotate config and grant keys without breaking installed apps.
# Report revenue
> Why revenue comes from your order system and how to send it.
Grona measures mobile campaigns against revenue, and the revenue that counts comes from your order system, sent server to server. The app reports purchases too, but those events are for joining, not for money. This page explains why, which fields Grona needs, and how your order records join to campaign events. ## Why revenue comes from your order system [Section titled “Why revenue comes from your order system”](#why-revenue-comes-from-your-order-system) The device user may be an attacker, and anything the app reports can be changed on the device, including a purchase event and its total. So Grona uses the app’s purchase events only to join orders to the campaign events of the same install and shopper. Revenue truth comes from your order system, which knows what was charged. ## What the app sends [Section titled “What the app sends”](#what-the-app-sends) Your app tracks a `purchase` event when an order completes. It carries these fields: | Field | Meaning | | ---------------------- | ----------------------------------------------------------------------------------- | | `order_id` | Your order id, for example `ord_1001` | | `total_minor` | The order total in minor units, for example `5200` for 52.00 | | `currency` | The currency of your latest basket snapshot, for example `USD` | | `campaign_product_ids` | Only the products in the order that Grona campaigns showed or added in this session | | `treatments` | The treatments your order backend applied, as display data | The event never carries the full basket. Campaign events are the exception: on keyed placements such as `cart_line`, they carry the line’s product id as their `key`, in every arm. So while a `cart_line` campaign is live, the product ids of the cart lines bound to it leave the device. See Track events for [Android](/mobile/android/track-events/) or [iOS](/mobile/ios/track-events/). ## What to send from your order system [Section titled “What to send from your order system”](#what-to-send-from-your-order-system) Send every order placed through the app, not only orders that redeemed a grant. Analysis compares the treatment arm with the control arm over everyone assigned, so orders from control and untreated shoppers count as much as treated ones. | Field | Example | Why Grona needs it | | -------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | | Order id | `ord_1001` | Matches the `order_id` in the app’s `purchase` event, which links the order to the install, the shopper and the campaign events | | Customer id | `cus_7Hq2Lm9x` | The same opaque id your app passes to `identify` | | Order total in minor units | `5200` | The revenue figure, as your order system charged it | | Currency | `USD` | Says which currency the total is in | | Redeemed grants | `jti`, `cmp`, `var`, `trt` and `val` for each grant the order used | Ties redemption to the grant Grona issued and to the campaign events that name it | Your redemption table already holds the grant fields for each order. See [Grants and order verification](/mobile/backend/grants-and-orders/). Caution Send the opaque customer id only. Order data for Grona carries no names, email addresses or phone numbers. ## How the records join [Section titled “How the records join”](#how-the-records-join) | Join key | Connects | | --------------- | ------------------------------------------------------------------------------------------------------------------------------ | | Order id | Your order record and the app’s `purchase` event. The event links the order to the install id, the customer id and the session | | Grant `jti` | The grant Grona issued, the `eligible` and `served` events that carry it as `grant_jti`, and your redemption record | | Campaign events | `eligible`, `served` and the other campaign events carry the campaign, variant, arm and unit, so revenue can be split by arm | The grant’s `jti` matters because issuance alone overstates who was treated. A minted grant can fail to reach the device, arrive after the shopper left, or expire first. Joining on `jti` ties exposure, issuance and redemption together. [Events and exposure](/mobile/concepts/events-and-exposure/) explains how each event is used in analysis. ## How to deliver it [Section titled “How to deliver it”](#how-to-deliver-it) There is no public endpoint for order data yet. Grona agrees the delivery method and format with you. Contact Grona through [Get help](/get-started/support/) to set it up. ## Next steps [Section titled “Next steps”](#next-steps) [Events and exposure](/mobile/concepts/events-and-exposure/)Which events the SDK records and how results are analysed. [Backend integration overview](/mobile/backend/overview/)The four jobs your servers do in a Grona mobile integration.
# Campaign activation and kill switch
> How campaigns are switched off quickly and what the SDK does while it cannot tell.
A campaign on a device can stop in three ways: a new signed configuration switches it off, the configuration expires, or an activation source in your app reports that the campaign is no longer active. This page explains each one, how fast it reaches a device, and what the SDK does when it cannot tell. Read it when you connect an external campaign system or plan how you would stop a campaign in a hurry. ## The activation rule [Section titled “The activation rule”](#the-activation-rule) For every campaign, the SDK computes:
```text
active = enabled AND now < expires_at AND (activation unknown OR any of the campaign's external_ids is active)
```
* `enabled: false` in the signed configuration always wins. * Past the configuration’s `expires_at`, every campaign in it is inactive. No fetch is needed for that. * Campaigns without `external_ids` ignore activation. ## Activation sources narrow, never widen [Section titled “Activation sources narrow, never widen”](#activation-sources-narrow-never-widen) An activation source is code you write and pass to the SDK at initialisation. It asks your own campaign or promotions system which campaigns are live, and returns the set of external ids that are active now, for example `CMP-1024`. When it does not know, it returns nothing (null). * A campaign with external ids stays active only while at least one of its ids is in the set. An answer can switch a campaign off. * An answer can never switch a campaign on. It cannot override `enabled: false`, revive an expired configuration, or add a campaign the configuration does not contain. * An unknown answer leaves the decision to the configuration: the campaign runs as the signed configuration says. The answer is unknown when the source returns null, fails, takes longer than 10 seconds, or is still running an earlier call, and when its last answer is more than 10 minutes old. For example, Acme Market’s promotions system ends `CMP-1024`, the external id of its weekend breakfast banner. At the next poll the source stops returning `CMP-1024`, and the banner disappears, including from screens already showing it. ### How the SDK calls your source [Section titled “How the SDK calls your source”](#how-the-sdk-calls-your-source) * It polls the source every 5 minutes while the app is in the foreground, and on demand when you call `refresh`. * It calls the source on a Grona background thread, sometimes at the same time as your own code. Make it thread-safe, and never touch views from it. * It waits at most 10 seconds for an answer. After that it treats activation as unknown and carries on with its other work. It does not call the source again until the earlier call has returned. To pass a source to the SDK, see Initialise the SDK for [Android](/mobile/android/initialize/) or [iOS](/mobile/ios/initialize/). ## Answers are trusted for at most 10 minutes [Section titled “Answers are trusted for at most 10 minutes”](#answers-are-trusted-for-at-most-10-minutes) An answer counts for at most 10 minutes after it arrives. A poll that fails, times out or finds the previous call still running makes activation unknown at once, and so does an answer older than 10 minutes. While activation is unknown, campaigns fall back to the configuration. So a single failed poll, at most 5 minutes after the last good one, lets a campaign your source switched off become active again on the shopper’s next visit. For a stop that must hold even when your source is down, switch the campaign off in the signed configuration as well. ## Kills stick for the visit [Section titled “Kills stick for the visit”](#kills-stick-for-the-visit) Once a campaign is switched off during a screen visit, it stays off for the rest of that visit. A later answer that switches it back on, or an answer that lapses to unknown, does not bring content back mid-visit. A campaign that is switched back on reappears on the shopper’s next visit to the screen. A visit is keyed on placement, screen and key, not on the view. Binding the same placement, screen and key again continues the visit, so it does not bring the campaign back. [Events and exposure](/mobile/concepts/events-and-exposure/) explains visits in full. ## How fast a stop reaches a device [Section titled “How fast a stop reaches a device”](#how-fast-a-stop-reaches-a-device) | Path | Worst case for an app in the foreground | | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Activation source | The next poll, at most 5 minutes later, plus up to 10 seconds for the source to answer | | New configuration with the campaign switched off | `ttl_seconds` after the last fetch. With the recommended 300 seconds, about 5 minutes while the config endpoint is healthy, and about 65 minutes while it is failing, because backoff can delay a refetch by up to 1 hour. Add up to 30 seconds for the fetch | | `expires_at` | At that moment, with no fetch | * A stop applies at once to content already on screen, as soon as the answer or the configuration lands. * A backgrounded app fetches and polls nothing on its own. It applies a stop when it returns to the foreground and the refresh is due. * Grona sets `ttl_seconds` to 300 in configurations that carry money treatments, so a stop reaches those devices within the times above. ## Refresh on demand [Section titled “Refresh on demand”](#refresh-on-demand) The SDK offers two refresh calls. * `refresh` without a listener polls the activation source and asks for a configuration fetch. It gives no signal when it is done. * `refresh` with a completion listener does the same work in a fixed order and tells you when it has finished. The refresh with a listener runs these steps: 1. It polls the activation source for an answer requested after your call. If a poll is already in flight, it waits for that poll and then polls again. 2. It fetches the configuration, unless the last fetch was less than 300 seconds ago, the SDK is backing off after failed fetches (for up to 1 hour), or the app is in the background. If a fetch is already in flight, it waits for that fetch instead. 3. It requests a re-evaluation of live placements, then calls your listener once, on the main thread. The 10-second source timeout and the 30-second fetch timeout still apply, so the listener always fires: after failures, after timeouts, and even when the SDK has not been initialised. Because stops stick for the visit, a campaign that the refresh switched back on does not reappear on the current screen. To show it at once, end the visit from the listener and then bind again: cancel the decision’s subscription for a host-rendered placement, or clear the slot’s screen for an SDK-rendered one. The platform guides cover both: host-rendered decisions for [Android](/mobile/android/host-rendered-decisions/) and [iOS](/mobile/ios/host-rendered-decisions/), and Grona components for [Android](/mobile/android/render-components/) and [iOS](/mobile/ios/render-components/). ## Next steps [Section titled “Next steps”](#next-steps) [Signed configuration](/mobile/concepts/signed-configuration/)How the configuration that switches campaigns on and off is verified. [Offline behaviour and caching](/mobile/concepts/offline-and-caching/)What renders without a connection and how configuration is cached.
# Assignment and bucketing
> How shoppers are assigned to variants and holdouts, identically on every platform.
Grona assigns each shopper to a variant or a holdout with a deterministic hash. Android, iOS and Grona’s servers compute the same arm for the same shopper without storing or syncing anything. This page publishes the algorithm, the order of the draws and test vectors, for anyone who reproduces assignment outside the SDK, for example in analysis or on a server. ## The algorithm [Section titled “The algorithm”](#the-algorithm)
```text
input = UTF-8 bytes of "v1:" + salt + ":" + unit_id
digest = SHA-256(input)
bucket = (first 8 bytes of digest, read as an unsigned big-endian integer) mod 10000
```
* Buckets run from 0 to 9999, so one bucket is one basis point (0.01%) of traffic. * Encode the input as UTF-8 explicitly. Never rely on a platform-default charset, and never use a language hash such as Java’s `String.hashCode` or Swift’s `hashValue`. * Changing anything in the algorithm re-buckets every shopper, so it is fixed. ## The unit [Section titled “The unit”](#the-unit) `unit_id` is one of two values, depending on the campaign’s `unit`: | `unit` | `unit_id` | Notes | | ---------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | `install` | The SDK’s install id, a lowercase UUID such as `8b0f6c2e-3d41-4f9a-9c57-2e6b1a0d4f13` | A new install id, for example after a reset, gives the install new buckets | | `customer` | The opaque customer id your app passes to `identify`, such as `cus_7Hq2Lm9x` | Shoppers who are not identified skip customer-unit campaigns | Customer ids must match `[A-Za-z0-9._:-]{1,128}`. The SDK ignores an identify call with any other id and keeps its current customer. Every campaign with a money treatment uses the `customer` unit, so logged-out shoppers never see offers. See [Shoppers and identity](/mobile/concepts/identity/). ## The draws, in order [Section titled “The draws, in order”](#the-draws-in-order) Each campaign makes up to three draws for a shopper. Each draw has its own salt, and a shopper held out at one step takes no later draw. | Order | Draw | Salt | Result | | ----- | ---------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------- | | 1 | Global holdout | `"global:" + workspace` | A bucket below `settings.global_holdout_bps` gives arm `global_holdout` | | 2 | Campaign holdout | `traffic.holdout_salt` | A bucket below `traffic.holdout_bps` gives arm `holdout` | | 3 | Variant | `traffic.variant_salt` | Walk `variants` in order, adding each `weight_bps`. The first variant whose running total exceeds the bucket wins | Weights are integer basis points and sum to 10,000. A variant marked `control` gives arm `control`, and any other variant gives arm `treatment`. Every arm logs an `eligible` event, but only `treatment` renders. A control or holdout shopper leaves the placement to the next campaign, which makes its own draws. See [Events and exposure](/mobile/concepts/events-and-exposure/) for how the arms are measured. ### The global holdout [Section titled “The global holdout”](#the-global-holdout) The global holdout means no Grona anywhere for that unit. Its salt is `global:` plus the workspace key, and it is drawn on each campaign’s own unit id. For the workspace `acme-market` and the customer `cus_7Hq2Lm9x`, the input is `v1:global:acme-market:cus_7Hq2Lm9x`. A customer held out sees no customer-unit campaign, and an install held out sees no install-unit campaign. The two draws are independent, so one shopper can be held out of customer-unit campaigns and still see install-unit ones, or the reverse. Compare the global holdout within one unit when you analyse it. ## Why the holdout and variant salts are separate [Section titled “Why the holdout and variant salts are separate”](#why-the-holdout-and-variant-salts-are-separate) Suppose one salt served both draws. The holdout would take the lowest buckets, and with a 50/50 split those buckets all belong to the first variant. Every shopper held out would come from one variant, which unbalances the split. Raising the holdout later would move more shoppers out of that one variant only. With separate salts, the holdout draw is independent of the variant draw. The holdout takes shoppers evenly from every variant, and changing `holdout_bps` never moves anyone from one variant to another. The worked example below shows this. ## Worked example [Section titled “Worked example”](#worked-example) Acme Market runs a weekend breakfast banner. The workspace holds out 5% of each unit (`global_holdout_bps` is 500), and the campaign uses this traffic block:
```json
{
"unit": "customer",
"holdout_salt": "acme-market:breakfast:holdout",
"holdout_bps": 1000,
"variant_salt": "acme-market:breakfast:variant",
"variants": [
{"id": "A", "weight_bps": 5000, "control": true},
{"id": "B", "weight_bps": 5000}
]
}
```
Variant A covers buckets 0 to 4999 and variant B covers 5000 to 9999. | Shopper | Global draw (below 500 holds out) | Campaign holdout draw (below 1000 holds out) | Variant draw | Arm | | -------------- | --------------------------------- | -------------------------------------------- | ------------ | ---------------- | | `cus_7Hq2Lm9x` | 1729 | 8434 | 6871, so B | `treatment` | | `cus_9Kd3Pz7v` | 5192 | 4603 | 2939, so A | `control` | | `cus_2Bn6Xc8j` | 5382 | 291 | Not drawn | `holdout` | | `cus_9Pa2Lf6h` | 162 | Not drawn | Not drawn | `global_holdout` | If Acme Market raises `holdout_bps` to 5000, `cus_9Kd3Pz7v` moves into the holdout, because its holdout bucket is 4603. `cus_7Hq2Lm9x` stays in variant B, because its variant bucket has not changed. ## Assignments are recomputed, not stored [Section titled “Assignments are recomputed, not stored”](#assignments-are-recomputed-not-stored) The SDK recomputes each assignment when it evaluates a campaign instead of storing it. Editing weights while a test runs moves shoppers between variants, as with any deterministic bucketing. To change a split, start a new salt instead. ## Test vectors [Section titled “Test vectors”](#test-vectors) Use these vectors in your unit tests. In vectors 1, 3 and 5 the first byte of the digest is `0x80` or above, so the top bit of the 64-bit value is set. These are the cases that catch signed arithmetic. | # | Salt | Unit id | Top bit set | Bucket | | - | ------------------------------- | -------------------------------------- | ----------- | ------ | | 1 | `global:acme-market` | `cus_7Hq2Lm9x` | Yes | 1729 | | 2 | `acme-market:breakfast:holdout` | `cus_7Hq2Lm9x` | No | 8434 | | 3 | `acme-market:breakfast:variant` | `cus_7Hq2Lm9x` | Yes | 6871 | | 4 | `acme-market:breakfast:variant` | `8b0f6c2e-3d41-4f9a-9c57-2e6b1a0d4f13` | No | 2650 | | 5 | `acme-market:breakfast:holdout` | `cus_2Bn6Xc8j` | Yes | 291 | The same vectors as JSON, with the full SHA-256 digest and the unsigned 64-bit value read from its first 8 bytes. The 64-bit values are strings because they do not fit a JavaScript number.
```json
[
{"salt": "global:acme-market", "unit_id": "cus_7Hq2Lm9x", "sha256": "d1ae8704da012c81223809c8da516a0b18a93732699205a1ecfd9cf11735a76a", "u64": "15109162254781721729", "bucket": 1729},
{"salt": "acme-market:breakfast:holdout", "unit_id": "cus_7Hq2Lm9x", "sha256": "11df08641a3dead2cde797d0fe7f2467fba2cbf2559637d3168b89a2fcff321d", "u64": "1287757244481268434", "bucket": 8434},
{"salt": "acme-market:breakfast:variant", "unit_id": "cus_7Hq2Lm9x", "sha256": "d997eac7e2e3415717d53562ccbd9c86bf3997f4039b627db94cd114a9c79391", "u64": "15679258771939606871", "bucket": 6871},
{"salt": "acme-market:breakfast:variant", "unit_id": "8b0f6c2e-3d41-4f9a-9c57-2e6b1a0d4f13", "sha256": "4ef48e953b1e76fa154bf54966c817e97304204c5a6fc76110e1d029a052a6fe", "u64": "5689329000868902650", "bucket": 2650},
{"salt": "acme-market:breakfast:holdout", "unit_id": "cus_2Bn6Xc8j", "sha256": "e6b6130f5e5e2613341cbb0eebd90f7ea1100640b3f0aee209089c5528d8028e", "u64": "16624496031213430291", "bucket": 291}
]
```
You can check a vector from a shell without any code. This prints the digest of vector 1:
```sh
printf '%s' 'v1:global:acme-market:cus_7Hq2Lm9x' | shasum -a 256
```
## Implementations [Section titled “Implementations”](#implementations) * Node.js bucket.mjs
```js
import { createHash } from 'node:crypto';
export function bucket(salt, unitId) {
const digest = createHash('sha256').update(`v1:${salt}:${unitId}`, 'utf8').digest();
return Number(digest.readBigUInt64BE(0) % 10000n);
}
```
The value is read as a `BigInt`, because a JavaScript number cannot hold 64 bits exactly. * Python bucket.py
```python
import hashlib
def bucket(salt: str, unit_id: str) -> int:
digest = hashlib.sha256(f"v1:{salt}:{unit_id}".encode("utf-8")).digest()
return int.from_bytes(digest[:8], "big") % 10000
```
* Java Bucketing.java
```java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
final class Bucketing {
static int bucket(String salt, String unitId) throws NoSuchAlgorithmException {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(("v1:" + salt + ":" + unitId).getBytes(StandardCharsets.UTF_8));
long x = 0;
for (int i = 0; i < 8; i++) x = (x << 8) | (digest[i] & 0xFF);
// x is signed, so take the unsigned remainder: 10000 = 2 * 5000
return (int) (((x >>> 1) % 5000) * 2 + (x & 1));
}
}
```
### JVM note for Java and Kotlin [Section titled “JVM note for Java and Kotlin”](#jvm-note-for-java-and-kotlin) A Java or Kotlin `long` that holds the first 8 bytes is signed. Whenever the top bit is set, as in vectors 1, 3 and 5, `x % 10000` returns a negative number. For vector 1 it returns -9887 instead of 1729. Use `((x >>> 1) % 5000) * 2 + (x & 1)`, which equals the unsigned remainder. Shifting right by one makes the value non-negative, and because 10000 is 2 times 5000, doubling the remainder of the half and adding the low bit gives the remainder of the whole. In Kotlin the operators are `ushr` and `and`. `Long.remainderUnsigned` gives the same result, but on Android it needs API level 26, and the formula runs on every API level. ## Next steps [Section titled “Next steps”](#next-steps) [Events and exposure](/mobile/concepts/events-and-exposure/)Which events each arm logs and how results are analysed. [Offers and grants](/mobile/concepts/offers-and-grants/)Why money offers use the customer unit and a server-signed grant.
# Grona components
> The native banner, progress, offer and message components and what each can show.
Grona components are the native views the SDK draws inside a slot view: a banner, a progress bar, an offer and a message. This page lists what each component shows and which fields a campaign can set, so you know what Grona can put in your app without a release. Your app only places the slot view; the content comes from the signed configuration. ## How components get their content [Section titled “How components get their content”](#how-components-get-their-content) Each SDK-rendered campaign carries a `render` object with a `component` field and that component’s fields. The SDK validates every field when it loads the configuration. A campaign with an unknown field, a missing required field or a malformed colour is inactive, and the rest of the configuration still applies. ### Text [Section titled “Text”](#text) Every text field is a map from locale to string, for example `{"en": "Weekend breakfast", "fr": "Petit-déjeuner du week-end"}`. The SDK picks the shopper’s locale from the context your app sets, and falls back to the workspace’s fallback locale (`en` unless the configuration says otherwise). * Text is plain text, at most 2,000 characters. No HTML, no links, no markdown. * The SDK strips control characters other than newline and tab, and the bidirectional embedding and override characters that could reorder digits in right-to-left scripts. * The SDK fills these templates: `{total}`, `{gap}`, `{reward}`, `{min}`, `{name}`, `{price}` and `{count}` (the number of products). Unknown tokens stay as written. * A known token with no value hides that text. If the text is required (a title or a call-to-action label), the whole component is hidden. * Money renders from minor units: whole amounts without decimals (`60`), otherwise with two (`12.99`). * Product names and prices always come from your app’s product data, never from the configuration. ### Colours [Section titled “Colours”](#colours) A campaign can set `style` with `background`, `text` and `accent`, each as `#RRGGBB`. Any colour the campaign leaves out falls back to your app’s theme tokens. The SDK also corrects text colours that would not be readable on the background. See [Theming and accessibility](/mobile/concepts/theming-and-accessibility/). ### Images [Section titled “Images”](#images) A banner image is one of: * An image bundled in your app, as `asset://img/`. The path must sit under `img/` in your app’s assets. * An `https` URL on a host listed in the workspace’s image host allowlist. Anything else is dropped. A dropped or failed image means no image; the component still renders. ## Banner [Section titled “Banner”](#banner) A card with an optional eyebrow, a title, optional body text, an optional image and an optional call-to-action button. Use it for promotions and nudges such as a weekend breakfast banner. | Field | Required | Meaning | | ---------------- | -------- | ---------------------------------------------------------------------------- | | `eyebrow` | No | Short label above the title | | `title` | Yes | Main line | | `body` | No | Supporting text | | `image` | No | Bundled asset or allowlisted `https` image, shown beside the text | | `cta` | No | `{label, action}`: the button label and what a tap does | | `style` | No | `background`, `text`, `accent` | | `fold_on_action` | No | When `true`, the banner collapses after its action succeeds. Default `false` | The call to action runs only from the shopper’s tap. It does one of three things: | Action | Shape | | -------------------- | --------------------------------------------------------------------------------------- | | Add several products | `{"type": "add_all", "products": [{"id": "sku_20877"}, {"id": "sku_31005", "qty": 2}]}` | | Add one product | `{"type": "add", "product": {"id": "sku_10432"}}` | | Navigate | `{"type": "navigate", "route": {"type": "category", "id": "dairy"}}` | Route types are `home`, `product`, `category`, `cart`, `checkout` and `deals`; your app resolves them with its own router. Adds are capped at 5 per line and 5 lines by default (your app can change both caps), and every product must resolve through your product lookup and be in stock. In a call-to-action label, `{total}` is the sum of the action’s products.
```json
{
"component": "banner",
"eyebrow": {"en": "This weekend"},
"title": {"en": "Restock the basics"},
"body": {"en": "Your usual items in one tap."},
"image": "asset://img/basics.png",
"cta": {
"label": {"en": "Add all for {total}"},
"action": {"type": "add_all", "products": [{"id": "sku_10432"}, {"id": "sku_20877"}, {"id": "sku_31005"}]}
},
"fold_on_action": true
}
```
## Progress [Section titled “Progress”](#progress) A card with a progress bar towards a basket threshold, such as free delivery. It is bound live to the basket total your app pushes, so it updates as the shopper adds and removes items. | Field | Required | Meaning | | ----------- | -------- | ------------------------------------------------------------------------- | | `title` | No | Heading above the bar | | `tiers` | Yes | `[{min_minor, reward_minor}]`, in ascending order of `min_minor` | | `text` | Yes | Line shown while below the threshold, usually with `{gap}` | | `met_text` | Yes | Line shown once the threshold is reached | | `footnote` | No | Small print below the bar | | `after_met` | Yes | `hold` keeps showing the met state; `next_tier` moves on to the next tier | | `style` | No | `background`, `text`, `accent` | `{gap}` is the amount still needed for the current tier and never goes below 0. `{min}` is the tier’s threshold and `{reward}` its reward.
```json
{
"component": "progress",
"title": {"en": "Free delivery"},
"tiers": [{"min_minor": 4000, "reward_minor": 599}],
"text": {"en": "Add {gap} more for free delivery"},
"met_text": {"en": "Free delivery is on us"},
"after_met": "hold"
}
```
## Offer [Section titled “Offer”](#offer) A card with an optional badge, a title and optional body text, tied to a money-bearing treatment such as 10% off the order. | Field | Required | Meaning | | ----------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- | | `badge` | No | Short label in a pill above the title | | `title` | Yes | Main line | | `body` | No | Supporting text | | `treatment` | Yes | `{type, value_milli}`: the treatment type and its value in thousandths (`10000` is 10, so 10% for a `percent_off` treatment) | | `style` | No | `background`, `text`, `accent` | Caution An offer renders only when the SDK holds a valid grant for this shopper, campaign, variant, treatment type and value, signed by Grona and fetched by your backend. Without one, the shopper is not treated and the placement falls through to the next campaign. Offer campaigns must assign by customer, so signed-out shoppers never see them. See [Offers and grants](/mobile/concepts/offers-and-grants/).
```json
{
"component": "offer",
"badge": {"en": "This weekend"},
"title": {"en": "10% off this order"},
"body": {"en": "Discount applied to your order."},
"treatment": {"type": "percent_off", "value_milli": 10000}
}
```
## Message [Section titled “Message”](#message) A compact line of text with an optional icon, for confirmations and short notes such as “Discount applied to your order”. | Field | Required | Meaning | | ------ | -------- | -------------------------------------------------------- | | `text` | Yes | The message | | `icon` | No | `star`, `check` or `info`. Any other value draws no icon | A message takes its colours from your theme; it has no `style` field.
```json
{
"component": "message",
"text": {"en": "Discount applied to your order"},
"icon": "check"
}
```
## Preview chip [Section titled “Preview chip”](#preview-chip) When a component is shown because of a preview pin, the SDK draws a small “Preview” chip over its corner, so a tester can tell pinned content from live content. See the preview links page for [Android](/mobile/android/preview-links/) or [iOS](/mobile/ios/preview-links/). ## Next steps [Section titled “Next steps”](#next-steps) * [Theming and accessibility](/mobile/concepts/theming-and-accessibility/) * Android: [Render Grona components](/mobile/android/render-components/) * iOS: [Render Grona components](/mobile/ios/render-components/)
# Consent and data minimisation
> How the SDK behaves before consent, after consent and after a refusal.
The SDK has three consent states, and it starts in the most restrictive one that still lets your app work. This page explains what the SDK keeps, sends and deletes in each state, and why some data stays on the device after a refusal. Read it with your privacy team before you connect your consent prompt to the SDK. Your app owns the consent prompt and stores the answer. The SDK does not remember consent between launches: it starts in `UNKNOWN` every time. So pass the stored answer with the consent call on each launch, and again whenever the shopper changes it. ## The three states [Section titled “The three states”](#the-three-states) | State | Events | Personalisation | | ----------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | | `UNKNOWN` (the default) | Kept in memory only, at most 500, oldest dropped. Nothing is written to disk and nothing is sent | Campaigns render | | `GRANTED` | Written to the on-disk queue and sent | Campaigns render | | `DENIED` | The in-memory buffer is dropped, the disk queue and preview pins are deleted, and nothing new is collected | Campaigns render; evaluation stays on the device | In every state, campaigns are decided on the device from the signed configuration, so the shopper’s experience does not depend on consent. Consent controls what is recorded and sent, not what renders. The configuration download carries no identifiers in any state. ## Events before consent [Section titled “Events before consent”](#events-before-consent) While consent is `UNKNOWN`, events wait in memory. When the shopper grants consent, the SDK drops that pre-consent buffer by default and sends only events recorded from then on. You can turn on the pre-consent events option to send the buffered events once consent is granted instead. It is off by default. Turn it on only if your counsel confirms that your consent text covers events recorded before the shopper answered. Events dropped before consent or under `DENIED` take no sequence number, so they do not show up as gaps. ## What `DENIED` deletes, what it keeps, and why [Section titled “What DENIED deletes, what it keeps, and why”](#what-denied-deletes-what-it-keeps-and-why) When the shopper refuses, the SDK deletes: * the in-memory event buffer; * the on-disk event queue; * preview pins. It keeps, on the device only: * the install id; * the configuration state: the cached signed configuration, and the record of the highest configuration version accepted. Nothing sends either of them under `DENIED`. They stay because deleting them would make things worse, not better. A new install id would reassign the shopper to new arms in every install-based campaign. Losing the version record would weaken the protection that stops an attacker replaying an older configuration, for example one with a campaign that has since been killed. Preview pairing also needs the install id, so the SDK refuses to pair a preview link while consent is `DENIED`. ## Minimisation in every state [Section titled “Minimisation in every state”](#minimisation-in-every-state) Whatever the consent state, the SDK limits what an event can carry: * Product ids and prices appear only for products a Grona campaign showed or added in the session. Other product views and basket changes are counted without them. * Purchases carry the order total, the applied treatments and only those session products, never the full basket. * Exception: on keyed placements such as `cart_line`, campaign events carry the line’s product id as their `key`, in every arm. So while a `cart_line` campaign is live, the product ids of the cart lines bound to it leave the device. * Events never carry search text, names, phone numbers or email addresses, and the customer id is your opaque internal id. * The SDK never reads the advertising identifier or other device identifiers. * The on-disk queue holds at most 1,000 events or 7 days. See [Events and exposure](/mobile/concepts/events-and-exposure/) for the full rules, and [Data, privacy and security](/get-started/data-privacy-security/) for Grona’s approach across products. ## Next steps [Section titled “Next steps”](#next-steps) * Android: [Consent](/mobile/android/consent/) and [Google Play Data safety](/mobile/android/play-data-safety/) * iOS: [Consent](/mobile/ios/consent/) and [App Store privacy and privacy manifest](/mobile/ios/privacy-manifest/)
# Events and exposure
> Which events the SDK records, when exposure counts, and how results are analysed.
The SDK records two kinds of events: campaign events it fires on its own as campaigns are decided, shown and used, and commerce events your app forwards. This page lists them, explains when exposure counts in each arm, and shows how Grona analyses results, including offers that need a grant. Read it before you design an experiment or check numbers from your own data. ## Event types [Section titled “Event types”](#event-types) | Type | Fired by | When | Arms | Data | | ------------------ | -------- | ------------------------------------------------------------------------- | -------------------- | --------------------------------------------------------------------------- | | `eligible` | SDK | A decision is computed for a subscribed placement | All | Offers, treatment arm only: `delivery`, and `grant_jti` when granted | | `served` | SDK | A treatment first reaches its slot view or listener | Treatment | Offers only: `delivery`, and `grant_jti` when granted | | `anchor_visible` | SDK | The anchor’s top edge has been on screen for one continuous second | All | None | | `exposure` | SDK | Half the content has been visible for one continuous second | Treatment | None | | `action` | SDK | The shopper tapped a call to action, or your app called a decision action | Treatment | `action` (`add`, `add_all`, `replace`, `navigate`), `product_ids`, `result` | | `dismiss` | SDK | The shopper dismissed content | Treatment | None | | `view_item` | Your app | A product detail view | Not a campaign event | `product_id`, `price_minor`, for session products only | | `add_to_cart` | Your app | Units added to the basket | Not a campaign event | `qty`; `product_id` and `price_minor` for session products only | | `remove_from_cart` | Your app | Units removed from the basket | Not a campaign event | `qty`; `product_id` for session products only | | `begin_checkout` | Your app | Checkout started | Not a campaign event | `total_minor` | | `purchase` | Your app | Order placed | Not a campaign event | `order_id`, `total_minor`, `currency`, `campaign_product_ids`, `treatments` | `arm` is `treatment`, `control`, `holdout` or `global_holdout`. Money is in minor units of the basket currency, so `total_minor: 1299` in `USD` is 12.99. Every event also carries a random `event_id` (the server removes duplicates on it), a per-install `seq` counter that reveals gaps from loss, the time, a `session_id`, the `install_id`, the `customer_id` when the shopper is identified, and the `config_version` the decision came from. Campaign events add `campaign_id`, `variant_id`, `arm`, `unit`, `placement` and `key`. A new session starts after the app has spent 30 minutes in the background. Events recorded while a preview pin is active carry `preview: true`, and events in demo mode carry `demo: true`. Both are excluded from analysis. ## Exposure symmetry [Section titled “Exposure symmetry”](#exposure-symmetry) An experiment compares the treatment arm with the control arm. Control shoppers see nothing, so Grona measures exposure in a way that does not depend on content. * **`eligible` is the analysis population.** It fires once per campaign, placement, key and screen visit when a decision is computed, through the same code path for every arm. * **`anchor_visible` measures the place, not the content.** It fires for every arm when the placement’s anchor has had its top edge inside the visible screen for one continuous second, while the window is visible and focused. A dialog on top or the app moving to the background stops the second. Every arm measures the same anchor: the slot view, or the view your app passed to the decision call. * **Empty anchors stay measurable.** An anchor with nothing to show stays visible at zero height, and a zero-width or zero-height anchor still counts when its top edge is on screen. * **`exposure` is diagnostic only.** It follows the mobile ad viewability standard (half the content for one second), but control has no content, so it cannot be compared across arms. * **`served` marks delivery.** It fires once per campaign, placement, key and screen visit when a treatment first reaches an attached slot view (within the per-screen cap) or a listener receives a non-null decision. A screen visit is keyed on placement, screen and key, not on the view, so list rows that rebind across views log each of these events once. See [Patterns for host-rendered decisions](/mobile/concepts/host-rendered-patterns/) for visits and the 2-second grace period. Caution If your app hides an anchor or removes it from layout when there is nothing to show, `anchor_visible` can never fire for control, and only treatment is measured. Keep empty anchors visible at zero height. ## Offers: delivery states [Section titled “Offers: delivery states”](#offers-delivery-states) An offer renders only with a grant, so a shopper assigned to its treatment arm may never see it. The `delivery` field on offer events says what happened: | `delivery` | Meaning | | ---------------- | ---------------------------------------------------------------------- | | `granted` | A verified grant matched; `grant_jti` names it | | `grants_pending` | No grant fetch has completed since the shopper was identified | | `no_grant` | A fetch completed and no grant matched, or the app has no grant source | | `demo` | Demo mode rendered it without a grant | | `preview` | A preview pin rendered it | `eligible` keeps the assigned arm. Its `delivery` is the state at the first decision of the visit, which is often `grants_pending`, because grants are fetched in the background after sign-in. If a grant lands later in the same visit, the offer replaces whatever the placement was showing and logs `served` with `delivery: granted`, not a second `eligible`. ## How results are analysed [Section titled “How results are analysed”](#how-results-are-analysed) * **Intent to treat uses `eligible`.** Compare the treatment arm with the control arm over everyone assigned, whether or not they saw content. * **The treated share uses `served` with `delivery: granted`.** For offers, that is the share of the treatment arm that actually saw the offer. Control can never receive an offer, so non-compliance is one-sided, and the effect on the treated is the intent-to-treat effect divided by that share. * **Do not count treated shoppers from grant issuance.** A grant can be minted and still fail to reach the device, arrive after the shopper left, or expire first. Join on `grant_jti` to tie exposure, issuance and redemption together. * **Revenue truth comes from your order system.** Client `purchase` events can be spoofed and are used only for joining. Send orders to Grona server to server; see [Report revenue](/mobile/backend/revenue/). ## Minimisation [Section titled “Minimisation”](#minimisation) The SDK sends the least data that still lets Grona measure campaigns. * A **session product** is a product that a Grona campaign showed or added in the current session. * `view_item`, `add_to_cart` and `remove_from_cart` are always counted, but they carry the product id and price only for session products. For any other product those fields are left out. * `purchase` carries the order id, total, currency and applied treatments, and in `campaign_product_ids` only the session products in the order. Your app passes the whole order; the SDK keeps only the session products. The full basket and browsing history never leave the device. * Exception: on keyed placements such as `cart_line`, campaign events carry the line’s product id as their `key`, in every arm. So while a `cart_line` campaign is live, the product ids of the cart lines bound to it leave the device. * Events carry no search text, names, phone numbers or email addresses. `customer_id` is your opaque internal id. See [Consent and data minimisation](/mobile/concepts/consent/) for what is kept and sent in each consent state. ## Delivery [Section titled “Delivery”](#delivery) * **When.** While the app is in the foreground, the SDK sends every 30 seconds, as soon as 20 events are queued, and when the app moves to the background. * **Batch size.** At most 50 events and 64 KB before compression per batch, sent gzip-compressed over HTTPS. * **Queue limit.** The on-disk queue holds at most 1,000 events or 7 days, oldest dropped first. This limit is applied at every start, in every consent state. * **Retries.** At most 3 retries with exponential backoff and full jitter. The SDK honours `Retry-After` on 429 and 503 responses and drops a batch on any other 4xx. * **Clocks.** Timestamps are Unix milliseconds from the device clock. The server corrects clock skew using its receive time. Without an events endpoint configured, the SDK sends no events and keeps no event queue. In debug builds, the debug view shows the most recent events from memory. ## Next steps [Section titled “Next steps”](#next-steps) * [Consent and data minimisation](/mobile/concepts/consent/) * Android: [Track events](/mobile/android/track-events/) * iOS: [Track events](/mobile/ios/track-events/)
# Patterns for host-rendered decisions
> Render host-owned UI from Grona decisions without layout jumps or skewed measurement.
With host-rendered decisions, your app draws the UI, so your app also decides how the screen behaves while a decision is pending, when there is none, and when one arrives late. This page gives the patterns that keep measurement fair and the layout stable. Use it alongside the platform pages for [Android](/mobile/android/host-rendered-decisions/) and [iOS](/mobile/ios/host-rendered-decisions/), which show the code. The running example is a frequently-bought-together rail on `pdp_below_buy`: a `rail` decision with a title and three products, drawn with your own product cells. ## Keep the anchor visible when the decision is null [Section titled “Keep the anchor visible when the decision is null”](#keep-the-anchor-visible-when-the-decision-is-null) The anchor is the view you pass to the decision call: the container your rail will occupy. The SDK measures visibility on it for every arm, including control and holdout, which never receive content. That is how both arms get the same `anchor_visible` measurement. When the decision is null, leave the anchor in the layout, visible, at zero height, with no children. Its width does not matter. When a decision arrives, fill the anchor and let it grow. Never hide the anchor Do not hide the anchor or remove it from layout when there is no decision (on Android, never set it `GONE` or `INVISIBLE`). A hidden anchor can never fire `anchor_visible`, so only shoppers in treatment would be measured, and the test compares a measured group against an unmeasured one. Debug builds log a warning once per hidden anchor (on iOS, for UIKit anchors only). A zero-height anchor stops sampling once `anchor_visible` has fired and resumes if it gains height, so empty control anchors cost nothing per frame. ## Decisions arrive asynchronously [Section titled “Decisions arrive asynchronously”](#decisions-arrive-asynchronously) Call the decision call on the main thread. The SDK delivers the decision through your listener, on the main thread, and never synchronously inside the call itself. So your screen always draws first without the decision, and the decision arrives a moment later, or not at all. The listener can fire more than once for the same subscription: whenever re-evaluation changes the result, for example after a basket or profile change, and with null when the campaign is killed. An unchanged result is not delivered again. Treat every call as the current state and redraw from it. The subscription lasts while the anchor view exists, including while it is off screen, and ends once your app releases the view. You can also end it yourself by cancelling it, for example when the screen closes. ## Avoid layout jumps [Section titled “Avoid layout jumps”](#avoid-layout-jumps) Because the decision is never there on first draw, plan for content appearing below or around the shopper’s current scroll position. * **Place the anchor where growth is harmless.** Below the fold, below the add-to-basket button, or at the end of a list, a growing anchor pushes nothing the shopper is reading. * **Reserve space only when the placement is always filled for this shopper.** A fixed-height placeholder in an anchor that then receives null leaves a gap. Reserving space for control shoppers also makes the two arms look different. * **Animate the growth.** Expand the anchor from zero height rather than snapping it open. * **Do not scroll the shopper.** If content arrives above the visible area, keep the shopper’s position anchored to what they were looking at. Keep spacing inside the anchor’s parent or inside the content you draw, not as margins on the anchor itself, so an empty anchor takes no space. ## Bind per row in lists, by key [Section titled “Bind per row in lists, by key”](#bind-per-row-in-lists-by-key) In a scrolling list, such as basket lines with `cart_line`, bind the decision per row and pass the row’s product id as the key. Bind each time a row is bound to a view. Binding the same anchor again replaces its previous subscription, so recycled rows can rebind freely. When the shopper scrolls and a row moves to a different view, the same placement, screen and key on the new view continues the same screen visit. You do not get a second `eligible`, `anchor_visible`, `served` or `exposure` for that row. Use the key, not the row position. Positions change when lines are added or removed; product ids do not. ## Visits and the grace period [Section titled “Visits and the grace period”](#visits-and-the-grace-period) A screen visit is identified by the placement, the screen and the key, not by the view. A view that leaves the screen and comes back keeps its visit. Another view that binds the same triple joins the visit while a view for it is still attached, or within 2 seconds after the last one detached or was replaced. That grace period covers list recycling and brief detaches during layout. A new visit starts: * when a view binds the triple more than 2 seconds after the last view for it detached; * when your app cancels the subscription; * when a slot view’s screen is set to null. Events such as `eligible` and `served` fire once per visit. Kills apply at once, even to content already on screen. Every other change, including a campaign that starts or is switched back on, waits for the next visit. A screen that stays alive, such as a tab’s root screen, keeps its visit, so a campaign that starts or is switched back on later appears there only after the visit ends. To show changes, end the visit when the screen reappears: set `screen` to `nil` (Android: `null`) and back, or cancel the decision’s subscription and request again. Each new visit logs `eligible` again. To show a re-enabled campaign at once after a refresh, do the same from the refresh listener. ## Render decision strings as plain text [Section titled “Render decision strings as plain text”](#render-decision-strings-as-plain-text) Decision titles, subtitles, badges, call-to-action labels and product labels are plain text. Render them without HTML, link detection or markdown. Treat `extras` as untrusted layout hints. Validate each value against what your layout accepts, such as a known set of positions, and ignore anything else. If a decision has a type your app does not render, treat it as no decision and keep the anchor at zero height. ## Report actions from your own taps [Section titled “Report actions from your own taps”](#report-actions-from-your-own-taps) When the shopper taps your add button, call the decision’s add, add-all or replace action, not your basket code directly. The SDK validates the products and quantities, calls your app’s add or replace, and records the `action` event with the result. Call dismiss when the shopper closes the content; it hides the decision for the rest of the session. See handle actions for [Android](/mobile/android/handle-actions/) or [iOS](/mobile/ios/handle-actions/). ## Next steps [Section titled “Next steps”](#next-steps) * [Events and exposure](/mobile/concepts/events-and-exposure/) * Android: [Host-rendered decisions](/mobile/android/host-rendered-decisions/) * iOS: [Host-rendered decisions](/mobile/ios/host-rendered-decisions/)
# Shoppers and identity
> Anonymous installs, identified customers, profiles and logout.
The SDK knows a shopper by up to two ids: an install id it creates itself, and an opaque customer id your app passes when the shopper signs in. This page explains both, how profiles attach to an identified customer, and what to do on logout or an erasure request. Read it before you wire sign-in and sign-out, because the ids decide which campaigns a shopper can see and which arm they get. ## The install id [Section titled “The install id”](#the-install-id) On first launch the SDK creates a random UUID, the install id. It identifies this installation of your app, not a person. The SDK uses it to assign shoppers in campaigns that assign by install, and it stamps it on every event. * The SDK owns the install id. Your app never sets it. * It is stored in the SDK’s own app-private storage, which the SDK excludes from backups on both platforms. On Android this also covers device transfers, so a restored or transferred device gets a new install id. iOS treats backup exclusion as a hint, so a restored device may keep its install id. Deleting the app removes it on both platforms. * The SDK never reads the advertising identifier or any other device identifier. * The install id changes when your app calls the reset call (below). Signed-out shoppers have only an install id, so they see only campaigns that assign by install. ## The customer id [Section titled “The customer id”](#the-customer-id) When a shopper signs in, your app passes their customer id to the SDK with the identify call. Use the same internal id your backend uses for that customer, for example `cus_7Hq2Lm9x`. Grona uses it to: * assign the shopper in campaigns that assign by customer, so they get the same arm on Android, iOS and in your backend; * fetch grants for offers, which your backend requests from Grona for this customer id (see the [Mobile grants API](/api/mobile-grants/)); * mark the shopper as signed in for audience rules; * stamp `customer_id` on events. The customer id must be opaque: an internal id that means nothing outside your systems. Customer ids must match `[A-Za-z0-9._:-]{1,128}`: letters, digits and `.`, `_`, `:` or `-`, at most 128 characters. The SDK ignores an identify call with any other id and keeps its current customer. Never pass personal data as the customer id Never pass an email address, a phone number or a national id number. The SDK cannot tell an opaque number from a phone number, so it accepts an all-digit id of 9 or more characters but logs a warning. Treat that warning as a bug in your integration. Campaigns that assign by customer skip shoppers who are not identified. Every campaign that carries a money treatment (every offer) assigns by customer, so signed-out shoppers never see offers. See [Assignment and bucketing](/mobile/concepts/assignment/). ## Profiles [Section titled “Profiles”](#profiles) A profile gives audience rules data about an identified customer: opaque segment ids, named lists of product ids (for example a list of the shopper’s usual products), and short attributes. Rules read them as `profile.segments`, `profile.lists.` and `profile.attributes.`. * **Profiles come only from your app.** Your app builds the profile from its own authenticated session, for example from your backend’s response after sign-in, and passes it to the SDK. Grona never serves profiles to the device by customer id, because the workspace key in your app is public and anyone could ask. * **A profile is bound to one customer.** Each profile names the customer id it belongs to. The SDK ignores a profile that does not match the identified customer. * **Until a matching profile arrives, profile rules are unknown.** A campaign whose audience depends on the profile stays inactive, even through a `not`, and logs no `eligible`. When the profile arrives, live placements re-evaluate. * **Signed-out shoppers have no profile**, so a profile-targeted campaign never shows to them. Segment ids are opaque labels such as `returning_buyer`, not descriptions of a person. Keep names, contact details and free text out of profiles. ## Sign-in, sign-out and switching accounts [Section titled “Sign-in, sign-out and switching accounts”](#sign-in-sign-out-and-switching-accounts) | Event in your app | What to call | What the SDK does | | --------------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------- | | Shopper signs in | Identify with the customer id, then set the profile | Fetches grants for this customer; profile rules become known | | Shopper switches account | Identify with the new customer id, then set the new profile | Clears the old profile and grants, fetches grants for the new customer | | Shopper signs out | Identify with null | Clears the customer id, profile and grants. Keeps the install id | | Erasure request, or a full reset on a shared device | The reset call | Clears everything below and issues a new install id | Identifying with null keeps the install id, so the shopper keeps their arms in install-based campaigns. That is usually what you want for a plain sign-out. ## Reset [Section titled “Reset”](#reset) The reset call returns the SDK to the state of a fresh install. Use it when a shopper asks for their data to be erased, when they delete their account, or when you want a signed-out device to stop being linked to the previous shopper. It clears: * the event queue, in memory and on disk; * the customer id and profile; * grants; * the basket snapshot; * preview pins; * the session; and it deletes the install id and creates a new one. Because the install id changes, the shopper is reassigned in campaigns that assign by install. ## Next steps [Section titled “Next steps”](#next-steps) * [Consent and data minimisation](/mobile/concepts/consent/) * Android: [Identify shoppers](/mobile/android/identify-shoppers/) * iOS: [Identify shoppers](/mobile/ios/identify-shoppers/)
# Offers and grants
> Why money offers render only with a server-signed grant, and what your backend checks.
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”](#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”](#how-a-grant-travels) 1. The shopper signs in, and your app calls `identify` with their opaque customer id, for example `cus_7Hq2Lm9x`. 2. The SDK asks your app’s grant source for grants. Your grant source calls your backend inside the shopper’s authenticated session. 3. Your backend fetches the shopper’s grants from Grona, server to server, and returns them to the app unchanged. 4. The SDK verifies each grant and keeps the valid ones in memory. 5. An offer renders only when a grant matches its campaign, variant, treatment type and value. 6. At checkout, your app reads the SDK’s active treatments, each with its grant token, and sends the tokens with the order. 7. 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”](#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:
```json
{"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:
```json
{
"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”](#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](/mobile/android/offers-and-grants/) or [iOS](/mobile/ios/offers-and-grants/). ## Delivery states [Section titled “Delivery states”](#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 `eligible` event keeps the assigned arm, `treatment`, and records the delivery state at the first decision of the visit. That is often `grants_pending`, because `identify` fetches grants in the background. * A grant that lands later in the visit makes the offer replace the fallback. That logs a `served` event with `delivery: granted` and the grant’s `jti`, not a second `eligible`. * Analysis takes the treated share from `served` events, 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](/mobile/concepts/events-and-exposure/). ## Preview and demo never grant [Section titled “Preview and demo never grant”](#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](/mobile/concepts/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”](#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`; * `sub` against the customer of the order; * `wks`; * `prv` is `false`; * 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. Caution Key single use on `jti`, never on the token string. ECDSA signatures are malleable, so one grant can have more than one valid token string. [Grants and order verification](/mobile/backend/grants-and-orders/) explains this and gives tested samples. ## Next steps [Section titled “Next steps”](#next-steps) [Grants and order verification](/mobile/backend/grants-and-orders/)Fetch grants for a shopper and verify them when an order is placed. [Security model](/mobile/concepts/security-model/)What the SDK trusts, what it never trusts, and what you must enforce.
# Offline behaviour and caching
> What renders without a connection and how configuration is cached.
The SDK decides campaigns on the device, so most of it keeps working without a connection. This page explains which configuration the SDK applies at launch and offline, how often it refreshes, what happens to events, grants and activation answers while the device is offline, and what a long time offline means for your campaigns. Read it before you decide what to put in the configuration you bundle with your app. ## Configuration at launch [Section titled “Configuration at launch”](#configuration-at-launch) At launch the SDK looks at two local copies of the signed configuration and applies the best valid one: * **The bundled configuration.** A signed configuration file you ship inside your app. It is what a fresh install runs before it has ever reached the network. * **The cached configuration.** The last configuration the SDK downloaded and accepted, stored with its ETag. Both copies are verified exactly like a download: signature, workspace, version, expiry and minimum SDK version. A copy that fails is ignored. Between valid copies, the higher version wins, and at equal version a downloaded copy outranks the bundled one. If neither is valid, nothing renders until a valid download arrives. See [Signed configuration](/mobile/concepts/signed-configuration/). If your app sets no configuration URL, the SDK runs on the bundled copy alone. ## Refresh [Section titled “Refresh”](#refresh) The SDK downloads the configuration at cold start and then again while the app is in the foreground. * **Interval.** The configuration’s `ttl_seconds` sets how often the SDK refreshes. The SDK clamps it to 300 to 86,400 seconds (5 minutes to 24 hours). A negative value rejects the configuration. * **Floor.** The SDK never downloads more often than every 300 seconds. An explicit refresh from your app still respects that floor. * **Conditional requests.** Each download sends the cached ETag, so an unchanged configuration costs a `304 Not Modified` with no body. * **Failures.** On a network error, a 429 or a 5xx response, the SDK backs off with jitter and honours `Retry-After`, but never waits more than 1 hour before trying again. Download timeouts are 30 seconds overall. * **Background.** A backgrounded app downloads nothing. It catches up on its next foreground, once a refresh is due. On any failure or rejection the SDK keeps the configuration it already has. ## What renders offline [Section titled “What renders offline”](#what-renders-offline) | | Offline behaviour | | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | SDK-rendered components and host-rendered decisions | Decided on the device from the current configuration, as usual | | Kills with `enabled: false` | Applied once a configuration carrying them is downloaded. A device that stays offline keeps the last configuration it accepted | | Configuration expiry | Applied with no download needed. Past `expires_at`, every campaign is inactive | | Activation answers | Trusted for at most 10 minutes, and unknown at once when a poll fails, for example offline. Unknown does not switch campaigns off | | Offers | Shown only while a grant fetched earlier is still valid; otherwise the placement falls through to the next campaign | | Images from remote hosts | Not shown if they cannot be downloaded; the component still renders. Bundled `asset://img/` images load from your app | ### Expiry and long periods offline [Section titled “Expiry and long periods offline”](#expiry-and-long-periods-offline) Every configuration has an expiry, at most 400 days after it was issued. Past it, every campaign is inactive, whether or not the device is online. A `304 Not Modified` does not extend the expiry, so Grona re-signs and serves a new version before the current one expires. A device that stays offline past the expiry of its best local copy shows nothing until it downloads a newer one. What to bundle A bundled configuration can show its campaigns offline until it expires. Keep offers out of the bundled configuration, or give it a short expiry. A bundled configuration should never carry a version higher than the one Grona serves, or installs will ignore downloads until the served version catches up. ### Activation answers [Section titled “Activation answers”](#activation-answers) If your app uses an activation source to switch campaigns off from an external system, the SDK polls it every 5 minutes in the foreground and waits at most 10 seconds for each answer. An answer counts for at most 10 minutes. After that, or as soon as a poll fails or times out (for example offline), activation is unknown. Activation can only narrow, so unknown leaves each campaign as the configuration says. See [Campaign activation and kill switch](/mobile/concepts/activation/). ### Grants [Section titled “Grants”](#grants) Grants are fetched through your app’s grant source when a shopper is identified, again shortly before the nearest grant expires, and at least once an hour, with a 10-second timeout. A failed fetch is retried with backoff of up to 15 minutes. Grants live in memory only and last at most 24 hours each. * Offline after sign-in, no fetch completes, so offers log `delivery: grants_pending` and are not shown. * A grant fetched earlier stays usable until it expires, so an offer already granted can still render offline. * After the app restarts, the SDK has no grants until a new fetch completes. Your order backend verifies any grant at order time, so an offline device can never apply a treatment on its own. See [Offers and grants](/mobile/concepts/offers-and-grants/). ## The event queue [Section titled “The event queue”](#the-event-queue) * **Before consent**, events are kept in memory only (at most 500) and never written to disk. * **After consent is granted** and with an events endpoint configured, events are written to an on-disk queue, so they survive restarts and periods offline. * **Limits.** The on-disk queue holds at most 1,000 events or 7 days, oldest dropped first. The limit is applied at every start. * **Offline**, sends fail and the events stay in the queue, within those limits, for a later send. The SDK retries each send at most 3 times with backoff, then waits for the next scheduled flush. See [Events and exposure](/mobile/concepts/events-and-exposure/) and [Consent and data minimisation](/mobile/concepts/consent/). ## Next steps [Section titled “Next steps”](#next-steps) * [Signed configuration](/mobile/concepts/signed-configuration/) * [Campaign activation and kill switch](/mobile/concepts/activation/) * Android: [Initialise the SDK](/mobile/android/initialize/) * iOS: [Initialise the SDK](/mobile/ios/initialize/)
# Placements
> The placement catalogue: where Grona content can appear in your app.
A placement is a named place in your app where Grona content can appear. Placements describe places, not use cases: `cart_top` is the top of the basket screen, whatever campaign fills it this week. Use this page to decide which placements to add to your screens before you wire up slot views or decisions. ## The placement catalogue [Section titled “The placement catalogue”](#the-placement-catalogue) The catalogue is a fixed set in this version. Every campaign names one of these placements, and a configuration that names any other placement makes that campaign inactive. | Placement | Where it typically sits | Keyed | | -------------------- | ------------------------------------------------------------------ | ------------------ | | `home_top` | At the top of the home screen, above the first content row | No | | `home_feed` | Between rows of the home screen feed | No | | `plp_inline` | Inline in a product listing or category page, between product rows | No | | `pdp_below_buy` | On a product detail page, directly below the add-to-basket button | No | | `cart_line` | On an individual basket line, one per line | Yes, by product id | | `cart_top` | At the top of the basket screen, above the lines | No | | `cart_below_lines` | On the basket screen, below the last line | No | | `checkout_above_pay` | On the checkout screen, directly above the pay button | No | | `order_confirmation` | On the order confirmation screen after a purchase | No | | `membership_top` | At the top of the membership screen | No | ## Placements, screens and keys [Section titled “Placements, screens and keys”](#placements-screens-and-keys) When you bind a placement, you also pass the current screen (home, categories, search, a category, a product, the basket, checkout, order confirmation, or a named other screen). Audience rules read the screen, for example the product being viewed on `pdp_below_buy`, so one placement can show different campaigns on different products. `cart_line` is keyed: you bind it once per basket line and pass the line’s product id as the key. The SDK evaluates each line separately, so one line can show a substitute while the others show nothing. A `substitute` decision is valid only on `cart_line`. Every other placement is bound without a key. A placement, screen and key together form a screen visit. Events such as `eligible` and `served` fire once per campaign and screen visit, not once per redraw. See [Events and exposure](/mobile/concepts/events-and-exposure/). ## How a placement is filled [Section titled “How a placement is filled”](#how-a-placement-is-filled) For each placement the SDK walks the campaigns that name it in priority order, highest first: 1. Campaigns that are disabled, killed, expired, or whose audience does not match are skipped. 2. The first campaign that passes is assigned an arm. A treatment shopper sees it. 3. A control or holdout shopper sees nothing from that campaign, and the next campaign in the placement gets its own assignment. 4. An offer without a valid grant yields to the next campaign in the same way. A preview pin puts its campaign first. See [Assignment and bucketing](/mobile/concepts/assignment/) and [Campaign activation and kill switch](/mobile/concepts/activation/). The configuration also caps how many SDK-rendered components show on one screen at once (1 to 10, set per workspace; 3 when the configuration does not set it). When more slots on a screen have content than the cap allows, the lower-priority ones stay empty. ## Empty placements [Section titled “Empty placements”](#empty-placements) When a placement has nothing to show, its anchor stays in the layout, visible, at zero height. That keeps the control arm measurable and stops your layout from depending on whether Grona has content. The slot view does this on its own. For host-rendered decisions, your app keeps the anchor visible at zero height itself. See [Patterns for host-rendered decisions](/mobile/concepts/host-rendered-patterns/). ## Next steps [Section titled “Next steps”](#next-steps) * [Rendering modes](/mobile/concepts/rendering-modes/) * [Grona components](/mobile/concepts/components/) * Android: [Render Grona components](/mobile/android/render-components/) * iOS: [Render Grona components](/mobile/ios/render-components/)
# Rendering modes
> Choose between Grona-rendered components and host-rendered decisions for each placement.
Every Grona campaign on mobile renders in one of two modes: the SDK draws a native component in a slot view, or your app draws its own UI from a decision. This page explains both, compares them, and shows how both are measured. Read it before you add your first placement, because the choice decides which call you wire up. ## SDK-rendered components [Section titled “SDK-rendered components”](#sdk-rendered-components) You place a slot view in your layout and give it a placement and the current screen. When a campaign wins that placement, the SDK draws one of four native components inside the slot: a banner, a progress bar, an offer or a message. The content (copy, images, colours, the call to action) comes from the signed configuration, so you can change it without an app release. The SDK styles components with theme tokens you pass at initialisation (fonts, colours and corner radius), and a campaign can override colours. Prices, totals and product names in the copy come from your app’s own product data, never from the configuration. When the shopper taps a call to action, the SDK validates the action and calls your app to add products or open a route. See [Grona components](/mobile/concepts/components/) for what each component shows. ## Host-rendered decisions [Section titled “Host-rendered decisions”](#host-rendered-decisions) You call the decision call for a placement, passing the view the content will occupy (the anchor) and the current screen. The SDK delivers a decision, or null when there is nothing to show. Your app then draws the result with its own views: your product cells, your prices, your stock badges and your add buttons. A decision is one of four types: | Type | Use | | -------------- | ------------------------------------------------------------------ | | `rail` | A horizontal list of products, such as frequently bought together | | `bundle` | A set of products added together | | `product_card` | A single product | | `substitute` | A replacement for an out-of-stock basket line, on `cart_line` only | A decision carries a title, subtitle, badge and call-to-action label (all optional), the list of products, and `extras`, a flat map of short layout hints such as a position in a feed. Products your app does not know, or has out of stock, are dropped before delivery, and a decision left with no products is not delivered. When the shopper taps your button, you call the decision’s add, add-all or replace action. The SDK validates the products and quantities, calls your app’s add or replace, and records the `action` event with the result. Treat any decision type your app does not recognise as no decision, because newer configurations can add types. See [Patterns for host-rendered decisions](/mobile/concepts/host-rendered-patterns/) for how to render them without layout jumps. ## Compare the modes [Section titled “Compare the modes”](#compare-the-modes) | | SDK-rendered components | Host-rendered decisions | | -------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------ | | What renders | Banner, progress, offer, message | Your own product views | | Visual control | Theme tokens and per-campaign colours | Complete: it is your UI | | New look without a release | Yes, within the four components | Only within what your layout already supports, steered by `extras` | | Products, prices, stock | From your app’s product lookup | From your app’s own views and data | | Offers with a reward | Yes, the `offer` component (with a grant) | No | | Actions | Add, add several, navigate, from the component’s button | Add, add all, replace, dismiss, from your own tap handlers | | Exposure reporting | Automatic | Automatic, measured on the anchor you pass | | Work in the app | Add a slot view | Add an anchor and draw the decision | Choose SDK-rendered components when the content is a message, a promotion or a progress nudge that does not need your product cells. Choose host-rendered decisions when the content is products, so they look and behave exactly like the rest of your catalogue. Each campaign uses one mode. A slot view only considers component campaigns for its placement, and the decision call only considers decision campaigns, so wire the call that matches the campaigns you plan for that placement. ## Both modes are measured the same way [Section titled “Both modes are measured the same way”](#both-modes-are-measured-the-same-way) A fair test needs the control arm measured the same way as the treatment arm, even though control shows nothing. So in both modes the SDK measures the same thing for every arm: the anchor. For SDK-rendered components, the anchor is the slot view itself. For host-rendered decisions, it is the view you pass to the decision call. * `eligible` fires for every arm when a decision is computed for the placement. * `anchor_visible` fires for every arm once the anchor’s top edge has been inside the visible screen for one continuous second. * `served` and `exposure` fire for treatment only, when content is delivered and when half of it has been visible for a second. An anchor with nothing to show stays visible at zero height. The slot view does this for you. With host-rendered decisions you must do it yourself. Caution Never hide or remove an anchor from layout when there is no decision (on Android, never set it `GONE` or `INVISIBLE`). A hidden anchor can never report `anchor_visible`, so only the treatment arm would be measured and the comparison is skewed. Debug builds log a warning once per hidden anchor (on iOS, for UIKit anchors only). See [Events and exposure](/mobile/concepts/events-and-exposure/) for the full event list. ## Next steps [Section titled “Next steps”](#next-steps) * [Placements](/mobile/concepts/placements/) * Android: [Render Grona components](/mobile/android/render-components/) and [Host-rendered decisions](/mobile/android/host-rendered-decisions/) * iOS: [Render Grona components](/mobile/ios/render-components/) and [Host-rendered decisions](/mobile/ios/host-rendered-decisions/)
# Security model
> What the SDK trusts, what it never trusts, and what your app and backend must enforce.
This page sets out what the Grona SDK trusts, what it never trusts, and what your app and your servers must do to keep an integration safe. The same contract binds the SDK, Grona’s servers and every host integration. Use it for your security review, together with [Data, privacy and security](/get-started/data-privacy-security/). ## Premises [Section titled “Premises”](#premises) 1. **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. 2. **The configuration is public.** The workspace key ships in your app, so anyone can fetch the configuration. It holds no personal data, no customer lists and only opaque segment ids. 3. **Network data is untrusted, even over TLS.** Integrity comes from signatures, not from the channel. 4. **Money never rests on a client assertion.** A treatment the device reports is display state until your backend verifies a Grona-signed grant. 5. **Fail closed for money and baskets, fail safe for the app.** A failed check removes the treatment or the campaign. It never blocks the app or the order. ## What the SDK trusts [Section titled “What the SDK trusts”](#what-the-sdk-trusts) | Input | Trusted when | Otherwise | | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | Configuration | Its signature verifies with a config key you pinned, and the workspace, version, time and SDK version checks pass | The SDK keeps the last configuration it accepted. With none, nothing renders | | Grant | Its signature verifies with a grant key you pinned, and it names the identified shopper, your workspace, and the offer’s campaign, variant, treatment type and value | The offer does not render | | Preview pin | Its signature verifies with a config key you pinned, it names this install’s id and your workspace, and it lasts at most 24 hours | The pin is ignored | | Product names, prices, stock and basket | Always, because they come from your app’s own code | | | Activation answers | Only to switch campaigns off, never on | | See [Signed configuration](/mobile/concepts/signed-configuration/) and [Offers and grants](/mobile/concepts/offers-and-grants/) for each check. ## What the SDK never trusts [Section titled “What the SDK never trusts”](#what-the-sdk-never-trusts) * **The network channel on its own.** The SDK does not pin certificates. Configurations, grants and pins are trusted for their signatures, not for the connection they came over. * **Configuration text as markup.** Text is plain text, with no HTML and no links. The SDK strips control characters other than newline and tab, and the bidirectional override characters that could reorder the digits of a price. * **Images.** Image bytes are not signed. The SDK loads only `asset://` paths under `img/` and https URLs on hosts the configuration lists, bounds every download and decode, and drops any image it refuses. The component still renders without it. * **Links.** A preview link must match an exact form: `://grona-preview?code=...` or `?clear=1`, and on iOS also `https:///grona-preview?...`. Any other parameter, host or path is rejected. * **Values the app reports at checkout.** Your backend applies only what a verified grant says. ## Host actions [Section titled “Host actions”](#host-actions) Grona components can add products to the basket and navigate through your app’s own code. The SDK bounds both. * Actions run only from a user tap on an SDK-rendered call to action, or from a call your app makes on a decision, which your app makes from its own tap. * An add asks for a quantity from 1 up to the maximum add quantity option, and covers at most the maximum add lines option. Both default to 5 and accept 1 to 20. * Every product in an add must resolve through your app’s product lookup and be in stock. Your add handler returns accepted, partial or rejected. * Navigation uses typed routes (`product`, `category`, `cart`, `checkout`, `deals` and `home`) that your own router resolves. There are no raw links. * Prices, totals and product names in copy come only from your app’s data, never from the configuration. * On Android, SDK buttons ignore a tap that passes through another app’s overlay, which blocks tapjacking. See Handle actions for [Android](/mobile/android/handle-actions/) or [iOS](/mobile/ios/handle-actions/). ## Identity and data [Section titled “Identity and data”](#identity-and-data) * The SDK owns a random install id, a UUID kept in app-private storage that the SDK excludes from backups (on Android also from device transfer; iOS treats backup exclusion as a hint). A reset replaces it. * The SDK never reads advertising identifiers or platform device ids. On Android that covers the advertising ID, OAID, ANDROID_ID and App Set ID. * `identify` takes an opaque internal customer id, never a phone number, email address or national id. The SDK cannot tell an opaque number from a phone number, so it accepts an all-digit id of 9 or more characters but logs a warning. Do not pass one. * Events carry product ids and prices only for products that Grona showed or added in this session. View, add and remove events for other products are still counted, without the id or price. A purchase carries the order total, treatment ids and only those session products. The full basket, browsing history and search text never leave the device. Exception: on keyed placements such as `cart_line`, campaign events carry the line’s product id as their `key`, in every arm. So while a `cart_line` campaign is live, the product ids of the cart lines bound to it leave the device. * Profiles reach the SDK only from your app, out of your own authenticated session. Grona never serves profiles keyed by customer id plus the public workspace key. * The configuration fetch carries no identifiers in any consent state. See [Shoppers and identity](/mobile/concepts/identity/) and [Consent and data minimisation](/mobile/concepts/consent/). ## Storage [Section titled “Storage”](#storage) The SDK keeps its files in app-private storage that the SDK excludes from backups, and writes them atomically from its own background thread. It adds no encryption of its own: the operating system’s app sandbox, file-based encryption, data minimisation and retention caps protect the files. | Stored | Notes | | ------------------- | --------------------------------------------------------------------------------------------- | | Install id | Replaced on reset | | Configuration state | The cached signed configuration, and the rollback and version-guard state | | Event queue | Written only after consent is granted. At most 1,000 events or 7 days, applied at every start | | Preview pins | Each one verified again whenever it is loaded | Grants live in memory only and are never written to disk. * When consent is denied, the SDK deletes the event queue and the preview pins. It keeps the install id and the configuration state, which never leave the device in that state. Deleting them would re-bucket shoppers and weaken rollback protection. * A reset deletes the queue and the pins, replaces the install id, and clears the customer, profile, grants and basket snapshot. ## Network [Section titled “Network”](#network) * **HTTPS only.** The SDK refuses plain http, even when your app allows cleartext traffic, and it does not follow redirects. * **Deadlines.** Each request has an overall deadline of 30 seconds, enforced by ending the connection, so a stalled response cannot hold a request open. On Android the connect timeout is 10 seconds and the read timeout 15 seconds; on iOS a request that stalls for 15 seconds fails. Image downloads have an overall deadline of 20 seconds. * **Size caps.** Past a byte cap, the SDK disconnects and stops reading instead of draining the rest of the response. * **Retries.** The SDK sends `If-None-Match` for the configuration, backs off exponentially with jitter, and honours `Retry-After` on 429 and 5xx responses. For the hosts the SDK contacts, see [Domains and network calls](/get-started/domains-and-network-calls/). ## Debug tooling [Section titled “Debug tooling”](#debug-tooling) The debug view ships as a separate package. On Android it is `grona-debug`, added with `debugImplementation`. On iOS it is the `GronaDebug` product, which Swift Package Manager links into every build, so guard its import and calls with `#if DEBUG`. * Opening the debug view does nothing in release builds: on Android when the package is missing or the app is not debuggable, on iOS when `GronaDebug` was compiled without `DEBUG`. * On Android the debug snapshot is empty in apps that are not debuggable. * The record of recent events exists only in debug builds, so release builds keep no copy of order ids, totals or product ids for it. * Local preview pins exist only in debug builds. On Android a repackaged build can be made debuggable; that exposes only the device user’s own data on their own device. Signed configurations, grants and pins still hold. See Test and debug for [Android](/mobile/android/test-and-debug/) or [iOS](/mobile/ios/test-and-debug/). ## Logging [Section titled “Logging”](#logging) The SDK’s logger is silent by default. When you pass your own logger, it never receives customer ids, basket contents, tokens or keys. ## What your app must do [Section titled “What your app must do”](#what-your-app-must-do) * Render decision strings, such as titles, subtitles, badges, call-to-action labels and product labels, as plain text: no HTML, no automatic link detection and no markdown. * Treat `extras` on a decision as untrusted layout hints. Check each value against what your layout accepts, and ignore anything else. * Make your logger, activation source and grant source thread-safe. Grona calls them from its background threads, sometimes at the same time as your own code, and they must never touch views. * Expect your host callbacks, such as product lookup and add to basket, on the main thread only. * Pass `identify` an opaque internal id, never a phone number, email address or national id. * Pin two config keys, the current and the next, and two grant keys in the same way if you use offers. * Never turn on demo mode in a production build. * Send grant tokens, not treatment values, with each order. ## What your backend must do [Section titled “What your backend must do”](#what-your-backend-must-do) * Serve your app’s grant source only inside the shopper’s authenticated session. Take the customer id from the session, and call Grona with your workspace secret key, which never ships in the app. * Verify every grant at order time: the signature with a pinned key, the expiry, the customer, the workspace and the preview flag. Use each `jti` once, inside the order transaction, re-check the campaign’s basket conditions against the real order, and take values only from the verified claims. A failed check means no treatment, and the order still succeeds. * Report revenue from your order system, server to server. * Pin Grona’s grant keys by `kid`, and add the next key before Grona signs with it. [Backend integration overview](/mobile/backend/overview/) covers each job. ## What Grona’s servers do [Section titled “What Grona’s servers do”](#what-gronas-servers-do) * Sign configurations and preview pins with a key held in a key management service, kept apart from the credentials that publish configurations. * Mint grants only after checking eligibility on the server. * Treat preview pairing codes as bearer secrets: single use, expiring within minutes, and rate limited per install id and per IP. * Deduplicate events on their `event_id`. ## Next steps [Section titled “Next steps”](#next-steps) [Backend integration overview](/mobile/backend/overview/)The jobs your servers do in a Grona mobile integration. [Rotate signing keys](/mobile/backend/key-rotation/)Rotate config and grant keys without breaking installed apps.
# Signed configuration
> How the SDK receives, verifies and protects campaign configuration.
Your app gets its campaigns from one configuration document per workspace, and Grona signs it. The SDK applies a signed copy bundled with your app at launch, then refreshes it from the network, and it checks the signature and a set of rules every time. This page explains the envelope, the checks, and what your app team and Grona each do. Read it before you ship the SDK and before you plan a key rotation. ## Who does what [Section titled “Who does what”](#who-does-what) | Your app team | Grona | | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | | Pins Grona’s config public keys in the SDK options: the current key and the next one | Signs every configuration with a key held in a key management service, kept apart from the credentials that publish it | | Bundles a signed copy of the configuration with the app and names it in the SDK options | Signs the bundled copy too | | Points the SDK at the https config URL, for example `https://api.grona.ai/v1/mobile/workspaces/acme-market/config` | Publishes each new signed version at that URL | | Ships an app release when the pinned keys change | Gives you the next public key before it signs with it | The configuration is public. The workspace key ships in your app, so anyone can fetch the configuration, and it holds no personal data, no customer lists and only opaque segment ids. The signature protects its integrity, not its secrecy. To set the keys, the config URL and the bundled copy, see Initialise the SDK for [Android](/mobile/android/initialize/) or [iOS](/mobile/ios/initialize/). ## The envelope [Section titled “The envelope”](#the-envelope) The configuration travels as a JWS in flattened JSON serialization (RFC 7515, section 7.2.2):
```json
{
"protected": "",
"payload": "",
"signature": ""
}
```
The protected header holds exactly three fields:
```json
{"alg": "ES256", "kid": "acme-market-2026-10", "typ": "grona-config+jws"}
```
* `alg` is `ES256`: ECDSA on the P-256 curve with SHA-256. * `typ` is `grona-config+jws`. * `kid` names one of the config keys your app pins. * Any other header field, another `alg` or `typ`, or an unknown `kid` rejects the configuration. The signature is the raw 64-byte pair of R and S values, as JOSE specifies, over the ASCII bytes of `protected + "." + payload` exactly as they appear in the envelope. Base64url values carry no padding. ## What the SDK checks [Section titled “What the SDK checks”](#what-the-sdk-checks) The SDK applies a configuration only when every check passes. Otherwise it keeps the last configuration it accepted. If it has none that is still valid, nothing renders. | Check | Rule | | ------------ | ------------------------------------------------------------------------------ | | Signature | Valid for the key that `kid` names | | `schema` | `1` | | `workspace` | Equals the workspace key your app passes to the SDK, for example `acme-market` | | `version` | At least the highest version this install has accepted | | `issued_at` | Unix seconds between 2020-01-01 and 2100-01-01 | | `expires_at` | After `issued_at`, at most 400 days after it, and still in the future | | `min_sdk` | At most the SDK’s own version | The SDK also rejects an envelope over 768 KB, a decoded payload over 512 KB, or JSON nested deeper than 32 levels. The SDK checks the bundled copy the same way, and it verifies a cached copy again every time it loads it. ### Pinned keys [Section titled “Pinned keys”](#pinned-keys) Your app passes Grona’s config public keys to the SDK when it initialises. Each key is a P-256 public key as base64 DER SubjectPublicKeyInfo, with its key id. Pass two, the current key and the next one. The SDK picks the key by the `kid` in the header and rejects every other `kid`, so a configuration signed with any key you did not pin never applies. With the next key already pinned, Grona can start signing with it and installed apps keep working. [Rotate signing keys](/mobile/backend/key-rotation/) gives the procedure. The same config keys verify preview pins, which Grona signs with the config key. ### The workspace check [Section titled “The workspace check”](#the-workspace-check) The payload names its workspace, and the SDK rejects a configuration for any other workspace even when the signature is valid. A valid signature alone is not enough to run in your app. ### Rollback protection [Section titled “Rollback protection”](#rollback-protection) Each install remembers the highest `version` it has accepted and never applies a lower one. An attacker who replays an older signed configuration, for example one in which a campaign was still switched on, gets nothing. The remembered version survives restarts. It also stays on the device when consent is denied, because deleting it would weaken this protection. ### Freeze protection [Section titled “Freeze protection”](#freeze-protection) Past `expires_at`, every campaign in the configuration is inactive, with no fetch needed. A device that cannot reach Grona, or a network that keeps serving an old copy, cannot hold a configuration in place forever. A `304 Not Modified` response does not extend `expires_at`, so Grona signs and serves a new version before the current one expires. ### Configurations that need a newer SDK [Section titled “Configurations that need a newer SDK”](#configurations-that-need-a-newer-sdk) A configuration can need a newer SDK, because its `schema` is unknown or its top-level `min_sdk` is above the SDK’s version. When such a configuration is validly signed, names your workspace and has a higher `version`, the SDK marks the configuration it is running as superseded. It then keeps only content that moves no money and no basket: * Offers, banners whose call to action adds to the basket, and host-rendered decisions stop at once, including on screen. * Banners without an add action, progress bars and messages continue. This fails closed: an old SDK cannot keep running an offer that the newer configuration may have switched off. The mark survives restarts and clears once the SDK accepts a configuration at that version or higher. To avoid stranding older installs, Grona sets `min_sdk` on individual campaigns instead of raising the top-level value, so an older SDK switches off only the campaigns it cannot run. ## Time values [Section titled “Time values”](#time-values) Every time in the configuration is in Unix seconds (UTC). The SDK bounds each value when it parses it: * `issued_at` falls between 2020-01-01 and 2100-01-01 (Unix seconds 1577836800 to 4102444800). * `expires_at` is after `issued_at` and at most 400 days later. * The same 2020 to 2100 bounds apply to the `iat` and `exp` claims in grants and preview pins. Timers and delays use saturating arithmetic, so no signed value can overflow them. ## Refresh [Section titled “Refresh”](#refresh) `ttl_seconds` in the payload is a refresh hint. The SDK clamps it to 300 to 86,400 seconds, never refreshes more often than every 300 seconds, and rejects a configuration with a negative value. * While your app is in the foreground, the SDK fetches the configuration every `ttl_seconds`. A backgrounded app fetches nothing. * The SDK sends `If-None-Match` with the ETag of its remote copy. A `304 Not Modified` keeps the current copy. * When the endpoint fails, the SDK backs off exponentially with jitter and honours `Retry-After` on 429 and 5xx responses. Neither holds refetches back by more than 1 hour. * A remote configuration with a higher `version` replaces the bundled one. At equal `version`, the remote copy wins. To ask for a refresh at a moment you choose, see [Campaign activation and kill switch](/mobile/concepts/activation/), which also covers how fast a change reaches a device. ## The bundled configuration [Section titled “The bundled configuration”](#the-bundled-configuration) The bundled copy lets campaigns render at first launch and offline. It is a signed configuration like any other and passes the same checks. * Grona never signs a bundled copy with a `version` above the one the server serves, so a bundle cannot block remote configurations through the rollback check. * The bundled copy carries its own short expiry. Once it expires, it renders nothing until a remote configuration arrives. * Grona keeps offers out of bundled copies or gives the bundle a short expiry, because a bundle that carries an offer can show it offline until the bundle expires. * Without a config URL, the SDK runs on the bundled copy alone. ## Demo mode [Section titled “Demo mode”](#demo-mode) Demo mode lets offers render without a grant, so a demo can run without a backend. It needs two switches: * the demo mode option in your app’s SDK options; * `settings.demo: true` in the signed configuration. Only when both are on do ungranted offers render. Their treatments then carry no token and are flagged as demo, and every event carries `demo: true`, which keeps it out of analysis. When only one switch is on, the SDK behaves as in production and logs a warning. Production configurations never set `settings.demo`, so a demo flag copied into a production app does nothing. Danger Demo mode is never for production. A backend must ignore demo treatments, which have no token. See [Grants and order verification](/mobile/backend/grants-and-orders/). ## Next steps [Section titled “Next steps”](#next-steps) [Rotate signing keys](/mobile/backend/key-rotation/)Rotate config and grant keys without breaking installed apps. [Campaign activation and kill switch](/mobile/concepts/activation/)Switch campaigns off quickly and refresh on demand. [Security model](/mobile/concepts/security-model/)What the SDK trusts, what it never trusts, and what you must enforce.
# Theming and accessibility
> Match Grona components to your app and keep them accessible.
SDK-rendered components take their look from theme tokens your app passes at initialisation, and campaigns can override colours. This page lists the tokens and their defaults, shows which token each component uses, and describes what the components do for accessibility and what your app and campaigns still need to do. It applies only to SDK-rendered components; host-rendered decisions use your own views and your own accessibility work. ## Theme tokens [Section titled “Theme tokens”](#theme-tokens) You pass a theme once, when you initialise the SDK. Every token is optional; anything you leave out uses the default. | Token | Default | Used for | | ------------- | ---------------------------------- | ------------------------------------------------------------------------------------------- | | Regular font | The system font | Body text | | Bold font | A bold version of the regular font | Titles, labels and buttons | | `primary` | `#054B3A` | Banner background, progress bar fill, offer badge text, message icon | | `onPrimary` | `#FFFFFF` | Banner text | | `accent` | `#6FCF97` | Banner eyebrow, progress bar fill once met, offer badge and border, message icon background | | `surface` | `#FFFFFF` | Progress, offer and message backgrounds | | `text` | `#1A1C1A` | Text on surface-based components, the progress card outline and bar track | | `mutedText` | `#6B706C` | Footnotes and offer body text | | Corner radius | 14 (density-independent units) | Card corners, clamped to 0 to 32 | The defaults are Grona’s own colours. Set at least `primary`, `onPrimary`, `accent` and your fonts so the components match your app. ## How components use the theme [Section titled “How components use the theme”](#how-components-use-the-theme) | Component | Background | Text | Accent | | --------- | ------------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------- | | Banner | `primary` | `onPrimary` | `accent` for the eyebrow; the button is an inverted pill in the text and background colours | | Progress | `surface` | `text`, with `mutedText` for the footnote | Bar fill `primary` while below the threshold, `accent` once met | | Offer | `surface` tinted with `accent` | `text`, with `mutedText` for the body | `accent` for the badge and border | | Message | `surface` tinted lightly with `text` | `text` | `accent` behind the icon | ## Per-campaign colours [Section titled “Per-campaign colours”](#per-campaign-colours) A banner, progress or offer campaign can set `style` with `background`, `text` and `accent`, each as `#RRGGBB`. Each colour the campaign sets replaces the matching theme token for that component only; anything it leaves out falls back to your theme. A malformed colour makes the campaign inactive. A message has no `style` and always uses your theme. Use per-campaign colours sparingly, for example a seasonal banner. Most campaigns read better in your app’s own colours. ## Dark mode [Section titled “Dark mode”](#dark-mode) The theme holds fixed colours, set once at initialisation, so components do not switch with the system’s dark appearance on their own in this version. If your app supports a dark theme, choose token values that read on the screens where your placements sit, and keep per-campaign colours to ones that work in both appearances. ## Accessibility [Section titled “Accessibility”](#accessibility) This section describes what the components do on Android and iOS in this version. Where the platforms differ, the item says so. The platform pages give the details: [Android](/mobile/android/theming/) and [iOS](/mobile/ios/theming/). ### What the components do [Section titled “What the components do”](#what-the-components-do) * **Readable contrast.** Before drawing, the SDK checks each text colour against its background. If titles, body text or button labels would fall below a contrast ratio of 4.5:1 (WCAG AA), it switches them to white or near-black, whichever reads better. Eyebrows and progress footnotes use a 3:1 threshold. This applies to your theme tokens and to per-campaign colours. * **Font scaling.** Text follows the shopper’s system font size: scalable units on Android, Dynamic Type on iOS. * **Line limits.** Each text field has a line limit (for example three lines for a banner title and four for body text) and ends with an ellipsis when it runs over. At very large font sizes, long copy is cut off. * **Touch targets.** The banner’s call-to-action button has a minimum touch target of 48 dp on Android, drawn as a 40 dp pill, and of 44 by 44 pt on iOS. * **Tapjacking protection.** On Android, buttons ignore taps that pass through another app’s overlay. * **Decorative visuals are hidden from screen readers.** Icons, the banner image and the progress bar graphic are not announced. Screen readers read the text and the button. * **Reduced motion.** The reveal and collapse animations and the progress bar fill are skipped when the shopper has turned system animations off on Android (8.0 and later) or turned on Reduce Motion on iOS. * **Right-to-left layouts.** Components use start and end alignment, and the progress bar fills from the start edge, so they mirror in right-to-left locales. The SDK sets no custom accessibility labels (content descriptions on Android) on components, and draws no dismiss control on them. ### What your campaigns and app should do [Section titled “What your campaigns and app should do”](#what-your-campaigns-and-app-should-do) * **Put the meaning in the text.** Because images, icons and the bar are not announced, the title, text and button label must carry the whole message. For a progress bar, say how much is left in `text` (for example “Add `{gap}` more for free delivery”), not only in the bar. * **Keep copy short.** Write titles that fit in two lines at default size, so they still fit at large font sizes. * **Label buttons with the outcome.** “Add all for `{total}`” reads better to a screen reader than “Tap here”. * **Check contrast in both appearances.** The automatic correction can replace a brand colour with white or near-black; pick tokens that already pass so the correction never needs to run. * **Test with a screen reader and the largest font size** on the screens that carry placements, using a preview link to pin each campaign. ## Next steps [Section titled “Next steps”](#next-steps) * [Grona components](/mobile/concepts/components/) * Android: [Theming](/mobile/android/theming/) * iOS: [Theming](/mobile/ios/theming/)
# Versioning and support policy
> How SDK versions, minimum OS versions and configuration compatibility are managed.
Three things carry versions in a Grona mobile integration: the SDK you ship in your app, the operating systems it supports, and the signed configuration it downloads. This page explains how each is versioned and how Grona keeps new configurations safe for apps already in the field. Read it before you upgrade the SDK or plan how long to support an older app release. ## SDK versions [Section titled “SDK versions”](#sdk-versions) The Android and iOS SDKs use semantic versioning (`MAJOR.MINOR.PATCH`). The current version is 0.1.0. Before 1.0, minor versions can break While the SDKs are at 0.x, a minor version (0.1 to 0.2) can contain breaking changes to the API or behaviour. The changelog lists every breaking change in the release that introduces it. Pin an exact version and read the changelog before upgrading. Under semantic versioning, breaking changes from 1.0 onwards come only in major versions. Release notes for each platform are in the [Android SDK changelog](/changelog/android-sdk/) and the [iOS SDK changelog](/changelog/ios-sdk/). ## Minimum OS versions [Section titled “Minimum OS versions”](#minimum-os-versions) | Platform | Minimum version | | -------- | ----------------------------------- | | Android | Android 7.0 (API level 24) or later | | iOS | iOS 16 or later | ## Configuration compatibility [Section titled “Configuration compatibility”](#configuration-compatibility) Grona changes what your app shows by publishing a new signed configuration, not a new SDK. Older SDKs stay in the field for as long as shoppers keep old app versions, so every configuration says which SDKs may apply it. ### Schema version [Section titled “Schema version”](#schema-version) Each configuration declares a `schema`. This version uses schema `1`, and the SDK accepts only the schema versions it knows. ### `min_sdk` [Section titled “min_sdk”](#min_sdk) A configuration and each campaign in it can carry a `min_sdk`, a semantic version: * **Campaign `min_sdk`.** A campaign that needs a newer SDK sets its own `min_sdk`. Older SDKs treat that campaign as inactive and apply everything else in the configuration, including every kill. This is how Grona ships new features without stranding older apps. * **Configuration `min_sdk`.** The top-level `min_sdk` is the lowest SDK version that may apply the configuration at all. An SDK older than that rejects it and keeps the configuration it already has. ### The superseded state [Section titled “The superseded state”](#the-superseded-state) An old SDK can be left behind: a validly signed configuration for its workspace arrives with a higher version, but the SDK must reject it because the schema is unknown or the top-level `min_sdk` is above its version. The SDK cannot read the new configuration, so it cannot tell whether the new one killed a campaign. It therefore marks the configuration it is running as superseded and fails closed: | Content | Under a superseded configuration | | --------------------------------------------------------- | ---------------------------------------------------- | | Offers | Stop at once, including on screen | | Banners whose call to action adds to the basket | Stop at once, including on screen | | Host-rendered decisions | Stop at once (they can add and replace basket lines) | | Banners without an add action, progress bars and messages | Continue | The superseded mark survives restarts. It clears once the SDK accepts a configuration at that version or higher, for example after the shopper updates the app. In debug builds, the debug view names the superseding version, and each stopped campaign reads `superseded by v`. Grona avoids this state by never raising the top-level `min_sdk` or `schema` while older SDKs are in the field, and by using campaign `min_sdk` instead. ### Configuration version [Section titled “Configuration version”](#configuration-version) Each configuration also has a `version` number that only ever increases for a workspace. The SDK remembers the highest version it has accepted on the install and refuses anything lower, so an older configuration cannot be replayed. See [Signed configuration](/mobile/concepts/signed-configuration/). ## Support windows [Section titled “Support windows”](#support-windows) No support windows or end-of-life dates are published for SDK versions before 1.0. ## Next steps [Section titled “Next steps”](#next-steps) * [Signed configuration](/mobile/concepts/signed-configuration/) * [Changelog](/changelog/)
# iOS API reference
> Every public type, method and option in the Grona iOS SDK 0.1.0, with Swift signatures and concurrency rules.
This page lists the public API of the Grona iOS SDK 0.1.0. The `Grona` product is the module `Grona`; the `GronaDebug` product is the module `GronaDebug`. Every type is public, and the value types are `Sendable`. The SDK never throws into your app, with one exception: the `GronaOptions` initialiser throws `GronaOptionsError` on invalid input. ## Concurrency [Section titled “Concurrency”](#concurrency) | API | Isolation | Notes | | --------------------------------------------------------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------- | | `Grona.start`, `Grona.decision`, `GronaSlotView`, `GronaDecision` actions, `Grona.presentDebugView` | Main actor | Swift checks at compile time that you call them on the main actor. | | Every other `Grona` method | None | Safe from any thread. Work runs off the main thread, and calls from any one thread are applied in the order you make them. | | `GronaHost` | Main actor | The SDK calls it on the main actor only. | | `GronaGrantSource`, `GronaActivationSource`, `GronaLogger` | None, `Sendable` | The SDK calls them off the main actor. | | Decision closures, refresh completions | Main actor | Never called synchronously from the call that registers them. | ## Grona [Section titled “Grona”](#grona)
```swift
public enum Grona
```
The SDK entry point. Call `start` once at launch. Every other call does nothing until then. | Member | Description | | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `static let version: String` | The SDK version, `"0.1.0"`. Compared against the minimum SDK version in configurations. | | `@MainActor static func start(options: GronaOptions, host: any GronaHost)` | Starts the SDK. Constant time: work is queued off the main thread. Ignored on repeat calls and in app extensions. Keeps a strong reference to `host` for the life of the process. | | `static func setConsent(_ consent: GronaConsent)` | Sets the consent state. The SDK starts in `.unknown` on every launch: events stay in memory and none are sent. | | `static func identify(_ customerID: String?)` | An opaque internal customer id matching `[A-Za-z0-9._:-]{1,128}`, never a phone number, email or national id; `nil` on logout. A new id clears the profile and grants. | | `static func setProfile(_ profile: GronaProfile?)` | Profile from your own authenticated session. Ignored unless it matches the identified customer. `nil` clears it. | | `static func setContext(_ context: GronaContext)` | Sets the shopper context that rules read. | | `static func setBasket(_ basket: GronaBasket)` | Push a fresh snapshot whenever the basket changes. Live components and decisions re-evaluate. | | `static func track(_ event: GronaEvent)` | Records a commerce event. | | `@MainActor @discardableResult static func decision(for anchor: UIView, placement: String, screen: GronaScreen, key: String? = nil, onDecision: @escaping @MainActor (GronaDecision?) -> Void) -> GronaSubscription` | Decision for a host-rendered placement, bound to `anchor`. `key` is the line’s product id for keyed placements such as `cart_line`. `onDecision` runs on the main actor, never synchronously, and again only when the result changes. Binding the same anchor again replaces the previous subscription. | | `static func activeTreatments() -> [GronaTreatment]` | Treatments currently shown to this shopper in live slots, each with the grant token your order backend must verify. | | `static func refresh()` | Polls the activation source and asks for a configuration refresh, subject to the 300-second floor. No completion signal. | | `static func refresh(completion: @escaping @MainActor @Sendable () -> Void)` | Refreshes now and calls `completion` once on the main actor when done, also on timeouts, failures and before `start`. | | `static func refresh() async` | As above, returning when done. In an async context, `Grona.refresh()` resolves to this form. | | `@discardableResult static func handle(_ url: URL) -> Bool` | Handles a preview link: `://grona-preview?...` or `https:///grona-preview?...`. Returns `true` when the link was Grona’s, whether or not it was accepted. | | `static func reset()` | Logout or erasure: new install id, and the queue, customer, profile, grants, basket snapshot, preview pins and session cleared. | ## GronaOptions [Section titled “GronaOptions”](#gronaoptions)
```swift
public struct GronaOptions: Sendable {
public init(
workspaceKey: String,
configURL: URL? = nil,
bundledConfig: URL? = nil,
configKeys: [String: String],
grantKeys: [String: String] = [:],
eventsURL: URL? = nil,
previewURL: URL? = nil,
activationSource: (any GronaActivationSource)? = nil,
grantSource: (any GronaGrantSource)? = nil,
theme: GronaTheme = GronaTheme(),
logger: (any GronaLogger)? = nil,
maxAddQuantity: Int = 5,
maxAddLines: Int = 5,
sendPreConsentEvents: Bool = false,
demoMode: Bool = false
) throws
}
```
Options for `Grona.start`. Each parameter is also readable as a `let` property of the same name. | Parameter | Description | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `workspaceKey` | The workspace key, a public identifier, not a secret. Must match `[A-Za-z0-9._:-]{1,128}`. | | `configURL` | Signed remote configuration (`https`). Without it the SDK runs on the bundled copy. | | `bundledConfig` | File URL of the signed bundled configuration, applied at launch and offline. | | `configKeys` | Trusted configuration signing keys, key id to base64 DER SubjectPublicKeyInfo of a P-256 key. Add current and next. At least one is required. | | `grantKeys` | Trusted grant signing keys, in the same format. | | `eventsURL` | Events endpoint (`https`). Without it the SDK sends no events and keeps no event queue. Debug builds keep the last 200 events in memory for the debug view. | | `previewURL` | Preview pairing endpoint (`https`). Without it preview links only clear pins. | | `activationSource` | Source of on and off answers from an external campaign system. | | `grantSource` | Fetches treatment grants from your backend. | | `theme` | Visual tokens for SDK components. | | `logger` | Receives SDK diagnostics. Silent when `nil`. | | `maxAddQuantity` | Highest quantity one action may add, 1 to 20. Default 5. | | `maxAddLines` | Most lines one action may add, 1 to 20. Default 5. | | `sendPreConsentEvents` | Send events buffered before consent once it is granted. Default `false`. | | `demoMode` | Demo only, never in production. Takes effect only when the signed configuration also enables demo mode. Default `false`. | ### GronaOptionsError [Section titled “GronaOptionsError”](#gronaoptionserror)
```swift
public enum GronaOptionsError: Error, Equatable, Sendable
```
| Case | Thrown when | | --------------------- | ------------------------------------------------------------------------- | | `invalidWorkspaceKey` | The workspace key does not match `[A-Za-z0-9._:-]{1,128}`. | | `insecureURL(URL)` | `configURL`, `eventsURL` or `previewURL` has a scheme other than `https`. | | `missingConfigKey` | `configKeys` is empty. | | `invalidAddCap` | `maxAddQuantity` or `maxAddLines` is outside 1 to 20. | ## GronaHost [Section titled “GronaHost”](#gronahost)
```swift
@MainActor
public protocol GronaHost
```
Implemented by your app. The SDK calls it on the main actor only and never reaches into app code. | Requirement | Description | | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | `func product(id: String) -> GronaProduct?` | Product facts for validation and copy. Must be a fast in-memory lookup; `nil` when unknown. | | `func add(_ lines: [GronaLine], source: GronaSource) -> GronaAddResult` | Adds lines after the shopper tapped a Grona call to action, or your code called a decision action. | | `func replace(productID: String, with line: GronaLine, source: GronaSource) -> GronaAddResult` | Replaces one basket line, for example an out-of-stock item with its substitute. | | `func navigate(to route: GronaRoute, source: GronaSource) -> Bool` | Opens `route` with your own router; `false` when the route is unknown or cannot open. | ## GronaSlotView [Section titled “GronaSlotView”](#gronaslotview)
```swift
@MainActor
public final class GronaSlotView: UIView
```
A place in your UIKit layout where Grona renders native components: banner, progress, offer and message. With nothing to show it stays laid out at zero height. Never hide it. | Member | Description | | -------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `init(placement: String)` | Creates a slot for `placement`. | | `override init(frame: CGRect)` | Creates a slot with an empty placement. Set `placement` before `screen`. | | `required init?(coder: NSCoder)` | For storyboards and nibs. Set `placement` in the Attributes inspector. | | `@IBInspectable var placement: String` | The placement. Set it before `screen`. | | `var screen: GronaScreen?` | Starts the slot; rules read it. An equal value continues the visit; `nil` ends it and empties the slot. | The slot changes its height through Auto Layout when content arrives or leaves. In table and collection views, set `selfSizingInvalidation` to `.enabledIncludingConstraints`. ## SwiftUI [Section titled “SwiftUI”](#swiftui)
```swift
public struct GronaSlot: View {
public init(placement: String, screen: GronaScreen?)
}
extension View {
public func gronaDecision(
placement: String,
screen: GronaScreen?,
key: String? = nil,
onDecision: @escaping @MainActor (GronaDecision?) -> Void
) -> some View
}
```
| Member | Description | | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GronaSlot(placement:screen:)` | A `GronaSlotView` for SwiftUI. Reports its height to SwiftUI layout. A `nil` screen ends the visit and empties the slot. | | `gronaDecision(placement:screen:key:onDecision:)` | Requests a decision with the modified view as the anchor, measured through a UIKit view placed behind it. Calls the latest `onDecision` you pass. A `nil` screen ends the subscription and its visit; a new placement, screen or key resubscribes. | ## GronaScreen [Section titled “GronaScreen”](#gronascreen)
```swift
public struct GronaScreen: Hashable, Sendable
```
Where the shopper is. Rules read it. | Member | Description | | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | | `let type: String` | The screen type, such as `home` or `product`. | | `let productID: String?` | The product, for product screens. | | `let categoryID: String?` | The category, for category screens. | | `let treatmentTypes: [String]` | For order confirmation: treatments your order backend applied. | | `let name: String?` | Your name for an `other` screen. | | `static let home`, `categories`, `search`, `cart`, `checkout` | Fixed screens. | | `static func category(_ categoryID: String) -> GronaScreen` | A category listing. | | `static func product(_ productID: String) -> GronaScreen` | A product page. | | `static func orderConfirmation(treatmentTypes: [String]) -> GronaScreen` | Order confirmation. `treatmentTypes` are the treatments your order backend actually applied. | | `static func other(_ name: String) -> GronaScreen` | Any other screen. | ## GronaBasket [Section titled “GronaBasket”](#gronabasket)
```swift
public struct GronaBasket: Sendable
```
Basket snapshot pushed with `Grona.setBasket`. Money is in minor units. | Member | Description | | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `init(currency: String, lines: [GronaBasket.Line] = [], totalMinor: Int? = nil)` | The currency code, such as `USD`, the lines, and the total rules compare against. `totalMinor` defaults to the sum of the lines. | | `static func empty(currency: String) -> GronaBasket` | An empty basket. | | `let currency: String`, `let lines: [GronaBasket.Line]`, `let totalMinor: Int` | The values passed, with `totalMinor` computed when it was `nil`. | | `GronaBasket.Line(productID: String, quantity: Int, unitPriceMinor: Int, inStock: Bool)` | One basket line. Each value is readable as a property. | ## GronaContext [Section titled “GronaContext”](#gronacontext)
```swift
public struct GronaContext: Sendable
```
Shopper context that rules can read. Signed-in state comes from `Grona.identify`. | Member | Description | | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `init(locale: String = "en", branchID: String? = nil, deliveryType: String? = nil)` | The shopper’s locale, your store or branch id, and your delivery type. Each value is readable as a property. | ## GronaProfile [Section titled “GronaProfile”](#gronaprofile)
```swift
public struct GronaProfile: Sendable
```
Per-shopper data from your authenticated session, bound to one customer id. Segment ids are opaque. | Member | Description | | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `init(customerID: String, segments: Set = [], lists: [String: [String]] = [:], attributes: [String: String] = [:])` | The customer the profile belongs to, segment ids, named lists of product ids, and string attributes. Each value is readable as a property. | ## GronaEvent [Section titled “GronaEvent”](#gronaevent)
```swift
public struct GronaEvent: Sendable
```
Commerce events your app forwards from its analytics layer. Money is in minor units; no search text, no full baskets. Exception: on keyed placements such as `cart_line`, campaign events carry the line’s product id as their `key`, in every arm. So while a `cart_line` campaign is live, the product ids of the cart lines bound to it leave the device. | Member | Description | | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `static func viewItem(productID: String, priceMinor: Int) -> GronaEvent` | A product view. | | `static func addToCart(productID: String, quantity: Int, priceMinor: Int) -> GronaEvent` | A cart add. `priceMinor` is the unit price. | | `static func removeFromCart(productID: String, quantity: Int) -> GronaEvent` | A cart removal. | | `static func beginCheckout(totalMinor: Int) -> GronaEvent` | Checkout started. | | `static func purchase(orderID: String, totalMinor: Int, productIDs: [String], treatments: [GronaTreatment]) -> GronaEvent` | A confirmed order. `productIDs` is the whole order; the SDK keeps only those Grona campaigns showed or added. | ## GronaDecision [Section titled “GronaDecision”](#gronadecision)
```swift
public struct GronaDecision: Equatable, Sendable
```
A product decision your app renders with its own views. Treat unknown types as no decision. Two values are equal only when they come from the same delivery. A decision delivered again, for example after a rebind, is a new value even when its content is the same, so SwiftUI redraws with its new action handle. Do not compare decisions field by field to skip updates. | Member | Description | | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | `let placement: String` | The placement. | | `let campaignID: String` | The campaign. | | `let variantID: String` | The variant. | | `let type: GronaDecisionType` | `.rail`, `.bundle`, `.productCard`, `.substitute`, or a type from a newer configuration. | | `let title: String?`, `let subtitle: String?`, `let badge: String?`, `let ctaLabel: String?` | Copy from the configuration. Render as plain text. | | `let products: [GronaDecisionProduct]` | The products to show. Never empty. | | `let key: String?` | For keyed placements such as `cart_line`: the line this decision belongs to. | | `let extras: [String: String]` | Layout hints from the configuration, such as `position` for feed rows. Untrusted. | | `let isPreview: Bool` | `true` while a preview pin produced this decision. | | `var source: GronaSource` | Placement, campaign and variant. | | `@MainActor @discardableResult func add(_ productID: String, quantity: Int = 1) -> GronaAddResult` | Call from the shopper’s tap. The SDK validates, logs the action and calls `GronaHost.add`. | | `@MainActor @discardableResult func addAll() -> GronaAddResult` | Adds every product in the decision. | | `@MainActor @discardableResult func replace(with productID: String) -> GronaAddResult` | Replaces `key` with `productID` through `GronaHost.replace`. | | `@MainActor func dismiss()` | Hides this decision for the rest of the session. | ### GronaDecisionType [Section titled “GronaDecisionType”](#gronadecisiontype)
```swift
public struct GronaDecisionType: RawRepresentable, Hashable, Sendable
```
| Member | Description | | -------------------------------------------------------- | ---------------------------------------------------------------------------------- | | `init(rawValue: String)`, `let rawValue: String` | The type as it appears in the configuration. | | `static let rail`, `bundle`, `productCard`, `substitute` | The types in this version: `"rail"`, `"bundle"`, `"product_card"`, `"substitute"`. | ### GronaDecisionProduct [Section titled “GronaDecisionProduct”](#gronadecisionproduct)
```swift
public struct GronaDecisionProduct: Hashable, Sendable
```
| Member | Description | | ----------------------- | ------------------------------------------- | | `let productID: String` | The product. | | `let quantity: Int` | The quantity `addAll()` adds. | | `let label: String?` | A per-product label from the configuration. | ## GronaSubscription [Section titled “GronaSubscription”](#gronasubscription)
```swift
public final class GronaSubscription: Sendable
```
The handle `Grona.decision` returns. The anchor keeps the subscription alive, so releasing the handle does not cancel it. | Member | Description | | --------------- | ----------------------------------------------------------------- | | `func cancel()` | Ends the subscription and its screen visit. Safe from any thread. | ## GronaTreatment [Section titled “GronaTreatment”](#gronatreatment)
```swift
public struct GronaTreatment: Sendable, CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable
```
A money-bearing treatment. Display state only: your order backend applies it only after verifying `token`. | Member | Description | | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `let campaignID: String` | The campaign. | | `let variantID: String` | The variant. | | `let placement: String` | Where it was shown. | | `let type: String` | The treatment type. | | `let valueMilli: Int` | Value in thousandths, so `10000` is 10 (10% off with `percent_off`). | | `let token: String?` | Grona-signed grant (compact JWS); `nil` in demo mode. A real backend ignores a treatment without a token: no treatment, and the order still goes through. | | `let isDemo: Bool` | `true` for a demo-mode treatment. | | `var description: String`, `var debugDescription: String`, `var customMirror: Mirror` | A summary that leaves the token out, so printing or dumping a treatment never writes the grant to your logs. | ## Value types [Section titled “Value types”](#value-types) | Type | Members | Description | | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `enum GronaConsent: Sendable` | `unknown`, `granted`, `denied` | The shopper’s consent state. | | `enum GronaAddResult: Sendable` | `accepted`, `partial`, `rejected` | The outcome of an add or replace. | | `struct GronaLine: Hashable, Sendable` | `init(productID: String, quantity: Int = 1)`, `let productID: String`, `let quantity: Int` | One product and quantity in an action. | | `struct GronaSource: Hashable, Sendable` | `let placement: String`, `let campaignID: String`, `let variantID: String` | Which campaign and variant an action came from. | | `struct GronaRoute: Hashable, Sendable` | `let type: GronaRouteType`, `let id: String?` | A typed destination your app resolves itself. Grona never passes raw links. | | `struct GronaRouteType: RawRepresentable, Hashable, Sendable` | `init(rawValue: String)`, `let rawValue: String`; `static let home`, `product`, `category`, `cart`, `checkout`, `deals` | Route types. Unknown types may arrive from newer configurations; return `false` from `GronaHost.navigate(to:source:)` for them. | ## GronaProduct [Section titled “GronaProduct”](#gronaproduct)
```swift
public struct GronaProduct: Sendable
```
Product facts from your app. Money is in minor units. | Member | Description | | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | | `init(id: String, name: String, priceMinor: Int, wasPriceMinor: Int? = nil, imageURL: URL? = nil, inStock: Bool = true)` | Id, display name, price, a previous price, the product image, and whether it can be added. Each value is readable as a property. | ## GronaTheme [Section titled “GronaTheme”](#gronatheme)
```swift
public struct GronaTheme: Sendable
```
Visual tokens so SDK components match your app. Each value is readable as a property. Colours are resolved once, the first time Grona draws a component. | Parameter | Default | | ----------------------- | ------------------------------------------- | | `regularFont: UIFont?` | `nil`, the system font | | `boldFont: UIFont?` | `nil`, the bold version of the regular font | | `primary: UIColor` | `#054B3A` | | `onPrimary: UIColor` | `#FFFFFF` | | `accent: UIColor` | `#6FCF97` | | `surface: UIColor` | `#FFFFFF` | | `text: UIColor` | `#1A1C1A` | | `mutedText: UIColor` | `#6B706C` | | `cornerRadius: CGFloat` | `14`, in points, kept between 0 and 32 | See [Theming](/mobile/ios/theming/) for what each token colours. ## Protocols your app implements [Section titled “Protocols your app implements”](#protocols-your-app-implements) | Type | Requirement | Description | | ----------------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `protocol GronaLogger: Sendable` | `func log(_ level: GronaLogLevel, _ message: String)` | Receives SDK diagnostics, called off the main actor. Never given customer ids, baskets, tokens or keys. | | `enum GronaLogLevel: Int, Comparable, Sendable` | `debug`, `info`, `warning`, `error` | The level of a log message. | | `protocol GronaActivationSource: Sendable` | `func activeExternalIDs() async -> Set?` | On and off answers from an external campaign system, called off the main actor. `nil` when unknown. After 10 seconds the SDK stops waiting and cancels the task. Activation can only switch campaigns off. | | `protocol GronaGrantSource: Sendable` | `func fetchGrants(customerID: String) async throws -> [String]` | Fetches grants from your backend, which asks Grona’s server for them. Called off the main actor. Returns compact JWS grants, or throws on failure. After 10 seconds the SDK stops waiting and cancels the task. | ## GronaDebug [Section titled “GronaDebug”](#gronadebug) The `GronaDebug` module. Import it in debug builds only. | Member | Description | | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `@MainActor static func Grona.presentDebugView(from viewController: UIViewController)` | Presents the debug view modally from `viewController`. Does nothing, and logs a warning, unless GronaDebug was compiled with the `DEBUG` condition. | | `struct GronaDebugView: View` with `init()` | The debug view for SwiftUI, for a sheet or a navigation destination. Empty unless GronaDebug was compiled with `DEBUG`. |
# Basket and context
> Push a fresh basket snapshot on every cart change and tell Grona the shopper's locale, store and delivery type.
This page keeps Grona’s view of the cart and the shopper’s situation current. Audience rules, progress bars and substitutes all read the basket, so push a fresh snapshot every time the cart changes. Context covers the shopper’s locale, store and delivery type, and changes less often. ## Push the basket [Section titled “Push the basket”](#push-the-basket) `GronaBasket` is a snapshot, not a live view of your cart. Build a new one from your cart state and pass it to `Grona.setBasket` after every change.
```swift
import Grona
func pushBasket(_ cart: AcmeCart) {
let basket = GronaBasket(
currency: "USD",
lines: cart.lines.map { line in
GronaBasket.Line(
productID: line.productID,
quantity: line.quantity,
unitPriceMinor: line.unitPriceMinor,
inStock: line.inStock
)
},
totalMinor: cart.totalMinor
)
Grona.setBasket(basket)
}
```
With milk and spaghetti in the cart, the snapshot is equivalent to:
```swift
GronaBasket(
currency: "USD",
lines: [
GronaBasket.Line(productID: "sku_10432", quantity: 2, unitPriceMinor: 349, inStock: true), // 2 x 3.49
GronaBasket.Line(productID: "sku_20877", quantity: 1, unitPriceMinor: 199, inStock: false), // out of stock
]
) // total 897, the sum of the lines
```
| Parameter | Default | What it sets | | ------------ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `currency` | Required | The currency code, for example `USD`. | | `lines` | `[]` | The cart lines, each a `GronaBasket.Line`. `unitPriceMinor` is in minor units, so `349` is 3.49. Set `inStock` to `false` for lines that are out of stock. | | `totalMinor` | The sum of the lines | The total that rules such as `basket.total` compare against. Defaults to the sum of `quantity * unitPriceMinor` over the lines. Set it when your cart total differs from that sum. | `GronaBasket.empty(currency: "USD")` is an empty basket. Push it when the cart is cleared, for example after an order. ## When to push [Section titled “When to push”](#when-to-push) 1. Push once at launch, as soon as your cart is loaded, so the first screen sees the right basket. 2. Push after every change: an add, a removal, a quantity change, a stock change, a cleared cart. Hook it into your cart store’s change notification, so changes from Grona actions and from your own screens take the same path. 3. Build the lines and the total from the same cart state. If your store notifies observers outside its lock, read the state the notification carries instead of reading the store again, so one snapshot never mixes two states. `setBasket` is safe from any thread. Each push replaces the previous snapshot. Live components, such as a free-delivery progress bar, and live decisions re-evaluate against it. What the basket drives: * `basket.total`, `basket.count` (units: the sum of quantities), `basket.items` and `basket.out_of_stock` in audience rules. * The progress component, which is bound live to `basket.total`. * Substitute decisions on `cart_line`, which show only while the line is in the basket and out of stock. * Decisions that exclude products already in the basket. The basket snapshot stays on the device. Events never carry the full basket. Exception: on keyed placements such as `cart_line`, campaign events carry the line’s product id as their `key`, in every arm. So while a `cart_line` campaign is live, the product ids of the cart lines bound to it leave the device. `Grona.reset()` clears the snapshot. Caution A stale basket is the most common cause of a progress bar or substitute that looks wrong. If it lags behind the cart screen, check that every path that changes the cart ends in `setBasket`. ## Set the context [Section titled “Set the context”](#set-the-context) `GronaContext` holds the shopper’s situation. Push it at launch and whenever one of its values changes, for example when the shopper picks another store or switches between delivery and pickup.
```swift
import Grona
Grona.setContext(GronaContext(locale: "en", branchID: "store-0042", deliveryType: "pickup"))
```
| Parameter | Default | Rule field | | -------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `locale` | `"en"` | `context.locale`. Also picks the language of localised campaign text, so use the same locale keys as your campaign text, such as `en` or `fr`. | | `branchID` | `nil` | `context.branch`. Your store or branch id. | | `deliveryType` | `nil` | `context.delivery_type`. Your own value, such as `delivery` or `pickup`. | The branch and delivery type are your own strings. Use the same values in your campaign rules. Signed-in state is not part of the context: rules read `context.signed_in` from [`Grona.identify`](/mobile/ios/identify-shoppers/). The screen the shopper is on is set per slot and per decision, not here. See [Render Grona components](/mobile/ios/render-components/). ## Next steps [Section titled “Next steps”](#next-steps) * [Render Grona components](/mobile/ios/render-components/) in your screens. * [Track events](/mobile/ios/track-events/) from your analytics layer.
# Consent
> Pass the shopper's consent answer to Grona on every launch and see what the SDK stores, sends and deletes in each state.
This page connects your consent flow to Grona. The SDK starts in `.unknown`, where it sends no events, and it does not remember consent between launches. You pass the shopper’s answer with `Grona.setConsent` on every launch and whenever it changes. Campaigns render in every state: consent controls what the SDK stores and sends, not what the shopper sees. ## Set consent [Section titled “Set consent”](#set-consent) 1. At launch, right after `Grona.start`, pass the answer your app stored:
```swift
import Grona
func applyStoredConsent(_ answer: AcmeConsentAnswer) {
switch answer {
case .accepted:
Grona.setConsent(.granted)
case .declined:
Grona.setConsent(.denied)
case .notAsked:
Grona.setConsent(.unknown)
}
}
```
2. Call `setConsent` again whenever the shopper changes their answer, for example from your privacy settings screen. `setConsent` is safe from any thread. Your app owns the consent prompt and stores the answer; the SDK starts in `.unknown` on every launch until you call it. Which purpose in your consent text covers Grona is your decision and your counsel’s. ## What each state does [Section titled “What each state does”](#what-each-state-does) | `GronaConsent` | Events | Stored on the device | | -------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------ | | `.unknown` (default) | Kept in memory only, at most 500, oldest dropped. Nothing is sent. | No events. | | `.granted` | Stored and sent. Events buffered before consent are dropped, unless `sendPreConsentEvents` is on. | The event queue, at most 1,000 events or 7 days. | | `.denied` | Buffer dropped, nothing collected. | The event queue and preview pins are deleted. | Under every state: * Campaigns still render. Evaluation happens on the device. * The configuration fetch carries no identifiers. * The install id and the configuration state stay on the device. Under `.denied` nothing sends them. Deleting them would move shoppers between arms and weaken the configuration’s rollback protection. Under `.denied` the SDK also refuses [preview pairing](/mobile/ios/preview-links/), because the pairing request carries the install id. ## Pre-consent events [Section titled “Pre-consent events”](#pre-consent-events) Events from before the shopper answered are kept in memory. By default they are dropped when consent is granted, so only events after the answer are sent.
```swift
let options = try GronaOptions(
workspaceKey: "acme-market",
configKeys: ["acme-market-2026-10": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE..."],
sendPreConsentEvents: true
)
```
With `sendPreConsentEvents: true`, the SDK sends the buffered events once consent is granted. The option is off by default. Caution Turn on `sendPreConsentEvents` only if your counsel confirms that your consent text covers events collected before the shopper answered. ## What the SDK stores on the device [Section titled “What the SDK stores on the device”](#what-the-sdk-stores-on-the-device) The SDK keeps its files in one folder inside your app’s container, `Library/Application Support/ai.grona.sdk/`: * the install id; * the configuration state: the cached signed configuration, its ETag and the record of the highest configuration version accepted; * the event queue, only once consent is granted; * preview pins. The folder is marked as excluded from backups. Its files use the `completeUntilFirstUserAuthentication` data protection class, so iOS keeps them encrypted and unreadable after a restart until the shopper first enters their passcode. The customer id, the profile, grants and the basket snapshot live in memory only, so identify the shopper and push the profile and basket again after every launch. The SDK does not use the keychain or `UserDefaults`, so deleting your app deletes everything the SDK stored, and a reinstall starts with a new install id. ## Erasure [Section titled “Erasure”](#erasure) `Grona.reset()` clears the event queue, the customer id, the profile, grants, the basket snapshot, preview pins and the session, and issues a new install id. Call it for an erasure request on the device. For data already sent, see [App Store privacy and privacy manifest](/mobile/ios/privacy-manifest/#deletion). ## Next steps [Section titled “Next steps”](#next-steps) * Read [Consent and data minimisation](/mobile/concepts/consent/). * [Connect offers and grants](/mobile/ios/offers-and-grants/).
# Handle actions
> Implement GronaHost so Grona can look up products, add to the cart, swap lines and navigate through your app's own code.
This page implements `GronaHost`, the protocol Grona uses to act inside your app. When a shopper taps a Grona call to action, or your code calls an action on a [decision](/mobile/ios/host-rendered-decisions/), the SDK checks the request and then calls your host. Your cart, your catalogue and your router do the work, so prices, stock rules and navigation stay yours. ## The protocol [Section titled “The protocol”](#the-protocol) `GronaHost` is a main-actor protocol, and the SDK calls its methods on the main actor only. It calls `add`, `replace` and `navigate` only from a shopper’s tap or from your own call on a decision, never on a timer or on its own. It calls `product(id:)` whenever it resolves content for a slot or a decision. That can follow a basket change, a grant, a refresh or a timer, so keep `product(id:)` a fast in-memory lookup with no side effects. | Method | Called when | Return | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | | `product(id:)` | The SDK resolves content for a slot or a decision, or checks an action, and needs a product’s name, price, image or stock state | A `GronaProduct`, or `nil` when the product is unknown | | `add(_:source:)` | The shopper tapped an add on a Grona banner, or your code called `add` or `addAll` on a decision | A `GronaAddResult` | | `replace(productID:with:source:)` | Your code called `replace(with:)` on a substitute decision | A `GronaAddResult` | | `navigate(to:source:)` | The shopper tapped a Grona call to action that opens a screen | `true` when a screen opened, `false` otherwise | ## A complete host [Section titled “A complete host”](#a-complete-host) AcmeGronaHost.swift
```swift
import Grona
@MainActor
final class AcmeGronaHost: GronaHost {
private let services: AcmeServices
init(services: AcmeServices) {
self.services = services
}
private var catalog: AcmeCatalog { services.catalog }
private var cart: AcmeCart { services.cart }
private var router: AcmeRouter { services.router }
func product(id: String) -> GronaProduct? {
guard let item = catalog.item(id: id) else { return nil } // in memory, no I/O
return GronaProduct(
id: item.id,
name: item.name,
priceMinor: item.priceMinor,
wasPriceMinor: item.wasPriceMinor,
imageURL: item.imageURL,
inStock: item.inStock
)
}
func add(_ lines: [GronaLine], source: GronaSource) -> GronaAddResult {
let added = lines.filter { cart.add(productID: $0.productID, quantity: $0.quantity) }
if added.isEmpty { return .rejected }
return added.count < lines.count ? .partial : .accepted
}
func replace(productID: String, with line: GronaLine, source: GronaSource) -> GronaAddResult {
guard cart.contains(productID: productID) else { return .rejected }
let replaced = cart.replace(productID: productID, with: line.productID, quantity: line.quantity)
return replaced ? .accepted : .rejected
}
func navigate(to route: GronaRoute, source: GronaSource) -> Bool {
switch route.type {
case .home:
return router.openHome()
case .deals:
return router.openDeals()
case .cart:
return router.openCart()
case .checkout:
return router.openCheckout()
case .product:
guard let id = route.id else { return false }
return router.openProduct(id)
case .category:
guard let id = route.id else { return false }
return router.openCategory(id)
default:
return false // a route type from a newer configuration
}
}
}
```
Here `AcmeServices`, `AcmeCatalog`, `AcmeCart` and `AcmeRouter` stand for your app’s own types. Pass `AcmeGronaHost(services: .shared)` to `Grona.start`, as [Initialise the SDK](/mobile/ios/initialize/) shows. Each `router` call returns `true` when it opened a screen. The cart changes go through your normal cart code, so your cart’s change notification [pushes the new basket](/mobile/ios/basket-and-context/) to Grona as it does for any other change. ## Product lookups [Section titled “Product lookups”](#product-lookups) `product(id:)` must be a fast in-memory lookup with no side effects. The SDK calls it on the main actor whenever it resolves content or checks an action, including after a basket change, a grant, a refresh or a timer, so do not read the disk or the network inside it. Return `nil` for products you do not know. | `GronaProduct` parameter | Default | Meaning | | ------------------------ | -------- | ------------------------------------------- | | `id` | Required | The product id, such as `sku_10432`. | | `name` | Required | The display name. | | `priceMinor` | Required | The price in minor units, so `349` is 3.49. | | `wasPriceMinor` | `nil` | A previous price, for strike-through copy. | | `imageURL` | `nil` | The product image. | | `inStock` | `true` | Whether the product can be added now. | Prices, totals and product names in Grona copy always come from this data, never from the configuration. A product your host does not know, or reports out of stock, is dropped from decisions and from add actions. ## Add results [Section titled “Add results”](#add-results) | `GronaAddResult` | Return it when | | ---------------- | -------------------------------------------------- | | `.accepted` | Every line was added, or the replacement was made. | | `.partial` | Some lines were added and some were not. | | `.rejected` | Nothing changed. | The SDK logs the result with the action, so return what actually happened in your cart. ## Add caps [Section titled “Add caps”](#add-caps) Two options bound what one action may add. Both default to 5 and accept 1 to 20. | Option | Limit | | ---------------- | -------------------------------------------------- | | `maxAddQuantity` | The highest quantity of one product in one action. | | `maxAddLines` | The most lines in one action. | Before calling `add`, the SDK keeps only lines with a quantity from 1 to `maxAddQuantity`, a product your host knows and reports in stock, and no repeated product. It keeps at most `maxAddLines` of them. If no line is left, the action returns `.rejected` and your host is not called. A replacement uses the cart line’s quantity, capped at `maxAddQuantity`. Set the caps in [`GronaOptions`](/mobile/ios/initialize/#options) if your cart has its own limits:
```swift
let options = try GronaOptions(
workspaceKey: "acme-market",
configKeys: ["acme-market-2026-10": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE..."],
maxAddQuantity: 3,
maxAddLines: 4
)
```
## Routes [Section titled “Routes”](#routes) `GronaRoute` is a typed destination. Grona never passes raw links, so your own router decides what each route opens. `route.type` is a `GronaRouteType`, a struct rather than an enum, so a `switch` over it needs a `default` case for types from newer configurations. | `route.type` | Raw value | `route.id` | | ------------ | ---------- | --------------------------------- | | `.home` | `home` | `nil` | | `.product` | `product` | A product id, such as `sku_10432` | | `.category` | `category` | A category id, such as `dairy` | | `.cart` | `cart` | `nil` | | `.checkout` | `checkout` | `nil` | | `.deals` | `deals` | `nil` | Return `false` for a route type you do not recognise, an id you cannot find, or a screen you cannot open right now, such as checkout with an empty cart. Return `true` only when a screen actually opened. ## Sources [Section titled “Sources”](#sources) Every call carries a `GronaSource` that says which campaign the action came from. | Property | Meaning | | ------------ | --------------------------------- | | `placement` | The placement, such as `home_top` | | `campaignID` | The campaign | | `variantID` | The variant within the campaign | You do not need it to complete the action. Use it if your own analytics should record where an add came from. ## Next steps [Section titled “Next steps”](#next-steps) * [Track events](/mobile/ios/track-events/) from your analytics layer. * [Connect offers and grants](/mobile/ios/offers-and-grants/).
# Host-rendered decisions
> Ask Grona for a product decision at a placement and render it with your own UIKit or SwiftUI views and cart buttons.
This page renders Grona decisions with your app’s own views. A decision tells you which products to show at a placement, such as a frequently-bought-together rail under the buy button, and you draw them with the product cells you already have. Use this when the content must look like the rest of your catalogue. For banners, progress bars, offers and messages, use a [slot](/mobile/ios/render-components/) instead. ## Decision types [Section titled “Decision types”](#decision-types) `GronaDecision.type` is a `GronaDecisionType`: | Value | Raw value | Typical use | | -------------- | -------------- | ---------------------------------------------------------------- | | `.rail` | `rail` | A row of products, for example in the home feed | | `.bundle` | `bundle` | Products to buy together, with an add-all button | | `.productCard` | `product_card` | One featured product | | `.substitute` | `substitute` | A replacement for an out-of-stock cart line, on `cart_line` only | `GronaDecisionType` is a struct, not an enum, because newer configurations may add types that your app does not know how to draw. Treat any other value as no decision. ## Request a decision [Section titled “Request a decision”](#request-a-decision) * UIKit 1. Add an anchor to the layout: the view the content will occupy. An empty vertical `UIStackView` has zero height and grows with its content. Keep it in the layout and visible. 2. Call `Grona.decision` with the anchor, the placement, the screen and a closure. It is a main-actor API, so call it from your view controller. ProductViewController.swift
```swift
import Grona
import UIKit
final class ProductViewController: UIViewController {
private let productID: String
private let contentStack = UIStackView()
private let togetherAnchor = UIStackView()
private var together: GronaSubscription?
init(productID: String) {
self.productID = productID
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) is not used")
}
override func viewDidLoad() {
super.viewDidLoad()
contentStack.axis = .vertical
togetherAnchor.axis = .vertical
contentStack.addArrangedSubview(ProductHeaderView(productID: productID)) // your own views
contentStack.addArrangedSubview(togetherAnchor)
// ... add contentStack to a scroll view
together = Grona.decision(
for: togetherAnchor,
placement: "pdp_below_buy",
screen: .product(productID)
) { [weak self] decision in
guard let self else { return }
if let decision, decision.type == .bundle {
showTogether(decision)
} else {
clearTogether() // stays in the layout at zero height
}
}
}
}
```
3. Render the decision with your own views. Take names, prices and images from your catalogue, and wire your buttons to the decision’s actions:
```swift
extension ProductViewController {
func showTogether(_ decision: GronaDecision) {
clearTogether()
let rail = TogetherRailView() // your own view
rail.titleLabel.text = decision.title ?? String(localized: "Frequently bought together")
for product in decision.products {
let cell = ProductCellView(productID: product.productID) // your own product cell
cell.onAdd = { decision.add(product.productID) }
rail.addProduct(cell)
}
rail.addAllButton.setTitle(decision.ctaLabel ?? String(localized: "Add all"), for: .normal)
rail.onAddAll = { decision.addAll() }
togetherAnchor.addArrangedSubview(rail)
}
func clearTogether() {
togetherAnchor.arrangedSubviews.forEach { $0.removeFromSuperview() }
}
}
```
* SwiftUI 1. Add an anchor: a container that exists whether or not there is a decision, such as a `VStack(spacing: 0)`. It is empty, and so zero height, when there is no decision. 2. Apply `.gronaDecision` to the anchor and keep the decision in state: ProductDetailView\.swift
```swift
import Grona
import SwiftUI
struct ProductDetailView: View {
let productID: String
@State private var together: GronaDecision?
var body: some View {
ScrollView {
VStack(spacing: 0) {
ProductHeader(productID: productID) // your own views
VStack(spacing: 0) {
if let together, together.type == .bundle {
TogetherRail(decision: together)
}
}
.frame(maxWidth: .infinity)
.gronaDecision(placement: "pdp_below_buy", screen: .product(productID)) { decision in
together = decision
}
}
}
}
}
```
3. Render the decision with your own views, and wire your buttons to the decision’s actions: TogetherRail.swift
```swift
import Grona
import SwiftUI
struct TogetherRail: View {
let decision: GronaDecision
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(decision.title ?? String(localized: "Frequently bought together"))
.font(.headline)
ScrollView(.horizontal) {
HStack {
ForEach(decision.products, id: \.productID) { product in
ProductCard(productID: product.productID) { // your own product card
decision.add(product.productID)
}
}
}
}
Button(decision.ctaLabel ?? String(localized: "Add all")) {
decision.addAll()
}
}
.padding(.vertical, 12) // spacing inside the content, not on the anchor
}
}
```
The closure runs on the main actor, never synchronously inside the call that subscribes, and runs again whenever the decision changes. An unchanged decision is not delivered again. `nil` means show nothing. Caution Keep the anchor in the layout and visible when the decision is `nil`: leave it empty at zero height. Grona measures `anchor_visible` on the anchor for every arm. A hidden or removed anchor is never measured, so the control arm would go unmeasured. In UIKit, never set `isHidden` or an `alpha` of 0 on it. In SwiftUI, never apply `.hidden()` or `.opacity(0)` to it or to a view that contains it. ## Shape the anchor [Section titled “Shape the anchor”](#shape-the-anchor) The anchor is measured the same way in every arm, so its shape matters as much as its content. * **Zero height when empty.** Put no padding, minimum height or height constraint on the anchor. Put spacing inside the content you draw, as `TogetherRail` does above. * **Zero stack spacing around it.** A `UIStackView` or SwiftUI stack adds its spacing next to a zero-height view. Use `setCustomSpacing(0, after:)` or `spacing: 0` around the anchor. * **SwiftUI: a container that always exists.** Apply `.gronaDecision` to a concrete container, such as a `VStack`, that is there even when the decision is `nil`. Do not apply it to a `Group`, to `EmptyView`, or to a view inside an `if`. A modifier on a `Group` applies to the group’s children, and an empty group has none, so the anchor would disappear with the content. The SDK measures a SwiftUI anchor through a UIKit view it places behind the modified view, with the same frame. So it sees the window and the UIKit views that clip, including the scroll views behind `ScrollView` and `List`. It does not see clipping that exists only in SwiftUI, such as `.clipped()` or `.mask`. For the full rule, see [How visibility is measured](/mobile/ios/render-components/#how-visibility-is-measured). ## Keyed placements [Section titled “Keyed placements”](#keyed-placements) `cart_line` decisions are per line. Pass the line’s product id as the key: * UIKit
```swift
// When you configure the cell for a cart line
Grona.decision(for: cell.substituteAnchor, placement: "cart_line", screen: .cart, key: line.productID) { [weak cell] decision in
cell?.showSubstitute(decision?.type == .substitute ? decision : nil)
}
```
* SwiftUI
```swift
import Grona
import SwiftUI
struct CartLineRow: View {
let line: AcmeCartLine
@State private var substitute: GronaDecision?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
CartLineContent(line: line) // your own row
VStack(spacing: 0) {
if let substitute, substitute.type == .substitute {
SubstituteOffer(decision: substitute) // calls substitute.replace(with:)
}
}
.frame(maxWidth: .infinity)
.gronaDecision(placement: "cart_line", screen: .cart, key: line.productID) { decision in
substitute = decision
}
}
}
}
```
A substitute shows only while its line is in the basket and out of stock, so [push the basket](/mobile/ios/basket-and-context/) with `inStock: false` for that line. Call `decision.replace(with:)` from the shopper’s tap to swap the line. ## The `GronaDecision` value [Section titled “The GronaDecision value”](#the-gronadecision-value) `GronaDecision` is a `Sendable` struct. Two values are equal only when they come from the same delivery. A decision delivered again, for example after a rebind, is a new value even when its content is the same, so SwiftUI redraws with its new action handle. Do not compare decisions field by field to skip updates. | Property | Type | Meaning | | ---------------------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `placement` | `String` | The placement it was requested for. | | `campaignID` | `String` | The campaign it came from. | | `variantID` | `String` | The variant within that campaign. | | `type` | `GronaDecisionType` | `.rail`, `.bundle`, `.productCard` or `.substitute`, or a type from a newer configuration. | | `title`, `subtitle`, `badge`, `ctaLabel` | `String?` | Copy from the configuration. Each may be `nil`. | | `products` | `[GronaDecisionProduct]` | The products to show, each with `productID`, `quantity` and an optional `label`. Products your app does not know or has out of stock are already removed. | | `key` | `String?` | For keyed placements such as `cart_line`, the line this decision belongs to. | | `extras` | `[String: String]` | Layout hints from the configuration, for example `position` for feed rows. | | `isPreview` | `Bool` | `true` while a [preview pin](/mobile/ios/preview-links/) produced this decision. You may badge it. | | `source` | `GronaSource` | Placement, campaign and variant, as passed to `GronaHost`. | A decision with no products left is not delivered, so `products` is never empty. ### Render it safely [Section titled “Render it safely”](#render-it-safely) * Render `title`, `subtitle`, `badge`, `ctaLabel` and product labels as plain text. In UIKit, set `text`, not `attributedText` built from HTML or Markdown, and turn off data detectors. In SwiftUI, pass the `String` to `Text`, which shows it verbatim. Never wrap it in `LocalizedStringKey` or `AttributedString(markdown:)`. * Treat `extras` as untrusted hints. Check each value against what your layout accepts and ignore anything else. * Take product names, prices and images from your own catalogue, never from the decision. ## Actions [Section titled “Actions”](#actions) Call these from the shopper’s own tap, on the main actor. `add`, `addAll` and `replace` check the request, log the action, call your `GronaHost` and return a `GronaAddResult`, which you may ignore. `dismiss` logs the dismissal and returns nothing. | Method | What it does | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `add(_:quantity:)` | Adds a product in the decision through `GronaHost.add`. `quantity` defaults to 1. | | `addAll()` | Adds every product in the decision at its `quantity`. | | `replace(with:)` | For substitutes: replaces the `key` line with the given product through `GronaHost.replace`, at the line’s current quantity (capped at `maxAddQuantity`). | | `dismiss()` | Hides this decision for the rest of the session. | Before it calls your host, the SDK drops lines whose product is not in the decision, whose quantity is outside 1 to `maxAddQuantity`, that repeat a product, or that your `GronaHost.product(id:)` does not know or reports out of stock. It keeps at most `maxAddLines` lines. If nothing is left, the call returns `.rejected` without calling your host, and the SDK logs the action with its `rejected` result. If the decision is stale, because its subscription has ended or now shows another campaign or variant, the call returns `.rejected` and logs nothing. When an action returns `.rejected`, the shopper still asked for the product. You may fall back to a plain add through your own cart code. ## Subscriptions and rebinding [Section titled “Subscriptions and rebinding”](#subscriptions-and-rebinding) `Grona.decision` returns a `GronaSubscription`. You may ignore it. * **The anchor keeps the subscription alive.** Releasing the `GronaSubscription` does not cancel it, unlike Combine’s `AnyCancellable`. The subscription ends when you call `cancel()` or when the anchor is deallocated. * **The anchor holds your closure.** Capture `self` and cells weakly, as the examples do, or they are never released. * **Binding the same anchor again replaces the previous subscription.** Cells can rebind each time they are configured. With the same placement, screen and key, the visit continues and the new closure receives the current decision. * **An anchor keeps its visit while its subscription lives**, also when it leaves its window and comes back, for example under a pushed view controller. * **The same placement, screen and key on another anchor continues the visit** while any anchor for it is in a window, and for 2 seconds after. So reused cells log `eligible` and `served` once. * **`cancel()` ends the visit.** Cancel and request again when you want a fresh visit on the same screen, for example after [`Grona.refresh`](/mobile/ios/initialize/#refresh-on-demand) brings a campaign back. In SwiftUI, pass `nil` as the screen for one update instead, as [Screen visits](/mobile/ios/render-components/#screen-visits) shows for slots.
```swift
extension CartViewController {
// Called each time a cell is configured for a cart line
func configure(_ cell: CartLineCell, with line: AcmeCartLine) {
cell.bind(line)
Grona.decision(for: cell.substituteAnchor, placement: "cart_line", screen: .cart, key: line.productID) { [weak cell] decision in
cell?.showSubstitute(decision?.type == .substitute ? decision : nil)
}
}
}
```
The SwiftUI modifier subscribes when the modified view is created and keeps the subscription, and its visit, while that view exists, including while it is off screen under a pushed view. It calls the latest closure you pass and resubscribes when the placement, screen or key changes. A `nil` screen ends the subscription. ## Next steps [Section titled “Next steps”](#next-steps) * [Handle actions](/mobile/ios/handle-actions/) in your `GronaHost`. * Read [Patterns for host-rendered decisions](/mobile/concepts/host-rendered-patterns/).
# Identify shoppers
> Tell Grona which customer is signed in, pass their profile from your own session and clear both at logout.
This page connects Grona to your sign-in flow. Identify the shopper when they sign in or when your app restores a session, pass their profile so audience rules can read it, and clear both at logout. Shoppers who are not identified still see campaigns that target installs, but never campaigns that target customers, such as every offer. ## Identify the shopper [Section titled “Identify the shopper”](#identify-the-shopper) 1. Call `Grona.identify` with your internal customer id as soon as you know it: after sign-in, and at launch when your app restores a saved session.
```swift
import Grona
func didSignIn(_ session: AcmeSession) {
Grona.identify(session.customerID) // "cus_7Hq2Lm9x"
}
```
2. Call `Grona.setProfile` with data from your own authenticated session. Bind the profile to the same customer id.
```swift
import Grona
let profile = GronaProfile(
customerID: "cus_7Hq2Lm9x",
segments: ["returning_buyer"],
lists: ["usual_items": ["sku_10432", "sku_20877"]],
attributes: ["store_format": "express"]
)
Grona.setProfile(profile)
```
3. At logout, call `Grona.identify(nil)` or `Grona.reset()`. See [Log out](#log-out). Both calls are safe from any thread. Call `identify` before `setProfile`, because a profile for any other customer is ignored. ## Rules for customer ids [Section titled “Rules for customer ids”](#rules-for-customer-ids) * **Opaque and internal.** Pass the id your systems use for the customer, such as `cus_7Hq2Lm9x`. Never pass an email address, a phone number or a national id. * **Format.** The id must match `[A-Za-z0-9._:-]{1,128}`. Any other value is ignored and the SDK logs a warning. * **All-digit ids.** The SDK cannot tell an opaque number from a phone number. It accepts an all-digit id of 9 or more characters but logs a warning. Do not pass one. * **Stable.** Use the same id on every device and on your backend. Customer-unit campaigns assign arms from it, and your [grant backend](/mobile/backend/grants-and-orders/) checks grants against it. Passing the id that is already identified does nothing. Passing a different id clears the profile and any grants, then starts a new grant fetch if you set a [grant source](/mobile/ios/offers-and-grants/). ## Set the profile [Section titled “Set the profile”](#set-the-profile) A profile carries per-shopper data that audience rules read through the `profile.*` fields. It comes only from your app, from your own authenticated session. Grona never serves profiles itself. | `GronaProfile` parameter | Default | Rule field | What it holds | | ------------------------ | -------- | --------------------------- | ----------------------------------------------------------------------------------------------------- | | `customerID` | Required | | The customer the profile belongs to. It must equal the id passed to `identify`. | | `segments` | `[]` | `profile.segments` | Opaque segment ids, such as `returning_buyer`. | | `lists` | `[:]` | `profile.lists.` | Named lists of product ids, such as the shopper’s usual items. Decisions can draw products from them. | | `attributes` | `[:]` | `profile.attributes.` | String attributes, such as a store format. | Until a matching profile is set, every rule that reads `profile.*` is unknown, and a campaign whose audience is unknown stays inactive. So a profile-targeted campaign never shows to a shopper who is not identified, not even through a `not` rule. When the profile arrives, live placements re-evaluate. The debug view shows `audience: profile unknown` for campaigns waiting on one. Call `Grona.setProfile(nil)` to clear the profile while the shopper stays identified. ## Log out [Section titled “Log out”](#log-out) Two calls end a signed-in session. They clear different amounts. | Call | Clears | Keeps | | --------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | | `Grona.identify(nil)` | Customer id, profile, grants | Install id, event queue, basket snapshot, preview pins | | `Grona.reset()` | Customer id, profile, grants, event queue, basket snapshot, preview pins and session, and issues a new install id | Configuration state | Use `reset()` for erasure requests and wherever the next shopper on the device must not inherit this install’s history. A new install id assigns install-unit campaigns afresh, so the device may move between arms. After `reset()`, push the basket again if the cart is not empty. ## Next steps [Section titled “Next steps”](#next-steps) * [Push the basket and context](/mobile/ios/basket-and-context/). * Read [Shoppers and identity](/mobile/concepts/identity/) for how install and customer units work.
# Initialise the SDK
> Start Grona once at launch from your App or app delegate, choose its options and connect the host your app implements.
This page starts the SDK when your app launches. You build a `GronaOptions` value, implement `GronaHost` so Grona can reach your cart and router, and call `Grona.start` once, from your SwiftUI `App` initialiser or your app delegate. Every other Grona call does nothing until `start` has run. ## Start Grona at launch [Section titled “Start Grona at launch”](#start-grona-at-launch) Grona provides the workspace key, the URLs and the signing keys for your workspace. The values below are Acme Market’s; replace them with yours. 1. Add the signed bundled configuration that Grona exports for your workspace to your app target, named `grona-config.json`. Drag it into Xcode’s project navigator, tick your app target, and check that it is listed under **Build Phases > Copy Bundle Resources**. The SDK applies it at launch and whenever it is offline. 2. Build the options and call `Grona.start`: * SwiftUI AcmeMarketApp.swift
```swift
import Grona
import SwiftUI
@main
struct AcmeMarketApp: App {
init() {
do {
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..."],
eventsURL: URL(string: "https://api.grona.ai/v1/mobile/events"),
previewURL: URL(string: "https://api.grona.ai/v1/mobile/preview/pair")
)
Grona.start(options: options, host: AcmeGronaHost(services: .shared))
} catch {
assertionFailure("Invalid Grona options: \(error)")
}
}
var body: some Scene {
WindowGroup {
HomeView()
}
}
}
```
* UIKit AppDelegate.swift
```swift
import Grona
import UIKit
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
do {
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..."],
eventsURL: URL(string: "https://api.grona.ai/v1/mobile/events"),
previewURL: URL(string: "https://api.grona.ai/v1/mobile/preview/pair")
)
Grona.start(options: options, host: AcmeGronaHost(services: .shared))
} catch {
assertionFailure("Invalid Grona options: \(error)")
}
return true
}
}
```
3. Implement `AcmeGronaHost` (outline below), then pass the shopper’s [consent](/mobile/ios/consent/), [identity](/mobile/ios/identify-shoppers/) and [basket](/mobile/ios/basket-and-context/) as your app learns them. ## How `start` behaves [Section titled “How start behaves”](#how-start-behaves) * **Constant time.** `start` queues its work off the main thread and returns. It does no disk or network work on the main thread. * **Never throws.** If start-up fails, the SDK logs through your `GronaLogger` and stays inactive. Your app carries on. * **Once only.** A second call is ignored, so you cannot change options after launch. * **Your app only.** In an app extension `start` does nothing and Grona stays inactive there. * **Before `start`.** Every other call does nothing. `Grona.decision` returns a subscription that never delivers. `Grona.refresh(completion:)` still calls its completion, and `await Grona.refresh()` still returns. * **Concurrency.** `start` and `Grona.decision` are main-actor APIs, and Swift checks at compile time that you call them on the main actor. Setters such as `setBasket` and `identify` are safe from any thread. The SDK calls your `GronaHost` on the main actor only. * **In order.** The SDK applies your calls in the order you make them from any one thread, including calls made right after `start`. So `setConsent` straight after `start`, or `identify` followed by `setProfile`, behaves as written. * **Your host stays alive.** The SDK keeps a strong reference to the host for the life of the process. Make it a long-lived object, not a view controller. * **Before the first passcode.** If iOS launches your app in the background after a restart, before the shopper has entered their passcode, the SDK’s files cannot be read yet. The SDK waits until they can be, rather than starting with a new install id. At start the SDK applies the bundled configuration, then fetches the remote one. It never refreshes the configuration more often than every 300 seconds. For how configurations are signed and checked, see [Signed configuration](/mobile/concepts/signed-configuration/). Caution The `GronaOptions` initialiser is the one call that throws. It throws `GronaOptionsError` for invalid input, such as a URL that is not `https` or no configuration key. Build the options from fixed values so the error shows up the first time you run the app. ## Options [Section titled “Options”](#options) `GronaOptions` is a struct. Pass the parameters you need in this order; every parameter except `workspaceKey` and `configKeys` has a default. | Parameter | Default | Validation | What it does | | ---------------------- | -------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `workspaceKey` | Required | Matches `[A-Za-z0-9._:-]{1,128}` | Your workspace key, for example `acme-market`. It is a public identifier, not a secret. | | `configURL` | `nil` | `https` only | Signed remote configuration. Without it the SDK runs on the bundled copy only. | | `bundledConfig` | `nil` | | File URL of the signed bundled configuration, applied at launch and offline, for example `Bundle.main.url(forResource: "grona-config", withExtension: "json")`. | | `configKeys` | Required | At least one | Trusted configuration signing keys, as key id to public key: the base64 DER SubjectPublicKeyInfo of a P-256 key. Add the current and the next key. | | `grantKeys` | `[:]` | | Trusted grant signing keys, in the same format. Needed for [offers](/mobile/ios/offers-and-grants/). | | `eventsURL` | `nil` | `https` only | Events endpoint. Without it the SDK sends no events and keeps no event queue. Debug builds keep the last 200 events in memory for the debug view. | | `previewURL` | `nil` | `https` only | Preview pairing endpoint. Without it [preview links](/mobile/ios/preview-links/) can only clear pins. | | `activationSource` | `nil` | | A `GronaActivationSource` that can switch campaigns off. See [Activation source](#activation-source). | | `grantSource` | `nil` | | A `GronaGrantSource` that fetches grants from your backend. See [Offers and grants](/mobile/ios/offers-and-grants/). | | `theme` | `GronaTheme()` | | Fonts, colours and corner radius for Grona components. See [Theming](/mobile/ios/theming/). | | `logger` | `nil` | | Receives SDK diagnostics. Silent when `nil`. See [Test and debug](/mobile/ios/test-and-debug/). | | `maxAddQuantity` | `5` | 1 to 20 | Highest quantity one Grona action may add. See [Handle actions](/mobile/ios/handle-actions/). | | `maxAddLines` | `5` | 1 to 20 | Most lines one Grona action may add. | | `sendPreConsentEvents` | `false` | | Send events buffered before consent once consent is granted. See [Consent](/mobile/ios/consent/). | | `demoMode` | `false` | | Demos only, never in production. See [Offers and grants](/mobile/ios/offers-and-grants/). | `GronaOptionsError` has four cases: | Case | Thrown when | | ---------------------- | ------------------------------------------------------------------------- | | `.invalidWorkspaceKey` | The workspace key does not match `[A-Za-z0-9._:-]{1,128}`. | | `.insecureURL(URL)` | `configURL`, `eventsURL` or `previewURL` has a scheme other than `https`. | | `.missingConfigKey` | `configKeys` is empty. | | `.invalidAddCap` | `maxAddQuantity` or `maxAddLines` is outside 1 to 20. | ## Implement `GronaHost` [Section titled “Implement GronaHost”](#implement-gronahost) `GronaHost` is how Grona works through your app’s own code. The SDK never reaches into your app any other way. It is a main-actor protocol with four methods: AcmeGronaHost.swift
```swift
import Grona
@MainActor
final class AcmeGronaHost: GronaHost {
private let services: AcmeServices
init(services: AcmeServices) {
self.services = services
}
// A fast in-memory lookup in your catalogue; nil when the product is unknown
func product(id: String) -> GronaProduct? {
services.catalog.gronaProduct(id: id)
}
// Add lines to the cart after the shopper tapped a Grona call to action
func add(_ lines: [GronaLine], source: GronaSource) -> GronaAddResult {
services.cart.addFromGrona(lines)
}
// Swap one cart line, for example an out-of-stock item for its substitute
func replace(productID: String, with line: GronaLine, source: GronaSource) -> GronaAddResult {
services.cart.replaceFromGrona(productID: productID, with: line)
}
// Open the route with your own router; false when the route is unknown
func navigate(to route: GronaRoute, source: GronaSource) -> Bool {
services.router.open(route)
}
}
```
Here `AcmeServices`, `catalog`, `cart` and `router` stand for your app’s own types. [Handle actions](/mobile/ios/handle-actions/) has a complete implementation and the rules for each return value. ## Activation source [Section titled “Activation source”](#activation-source) An activation source lets an external campaign system switch Grona campaigns off. Each campaign can list external ids, such as `CMP-1024`. When your source reports which external ids are active, a campaign that lists ids stays active only while at least one of them is active. Activation can only switch campaigns off, never on, and campaigns without external ids ignore it. AcmeActivationSource.swift
```swift
import Grona
struct AcmeActivationSource: GronaActivationSource {
let campaigns: AcmeCampaignClient
// Called off the main actor. After 10 seconds the SDK stops waiting and cancels the task.
func activeExternalIDs() async -> Set? {
do {
return try await campaigns.fetchActiveIDs() // for example ["CMP-1024"]
} catch {
return nil // unknown: Grona does not narrow anything
}
}
}
```
Pass it as `activationSource: AcmeActivationSource(campaigns: campaignClient)` in your options. * Return `nil` when you do not know. Unknown never switches a campaign off. * The SDK polls the source every 5 minutes while the app is in the foreground. An answer is trusted for at most 10 minutes. A poll that fails, times out or finds the previous call still running makes activation unknown at once. * After 10 seconds the SDK stops waiting, treats the answer as unknown and cancels the task. 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. * The protocol requires `Sendable`. The SDK calls it off the main actor, sometimes while your own code runs, so keep it thread-safe and away from your views. For how activation fits the kill switch, see [Campaign activation and kill switch](/mobile/concepts/activation/). ## Refresh on demand [Section titled “Refresh on demand”](#refresh-on-demand) The SDK refreshes on its own. When you need a refresh now, for example after a pull to refresh, call one of these:
```swift
Grona.refresh() // no completion signal
Grona.refresh {
// Main actor, called once, also on timeouts and failures
}
```
In async code, use the async form:
```swift
await Grona.refresh()
```
All three poll the activation source and ask for a configuration fetch, subject to the 300-second floor. The completion and async forms wait for an activation answer requested after the call (10-second timeout), then for the fetch and re-evaluation, before the completion runs or the async call returns. In an async context Swift chooses the async form for `Grona.refresh()`, so write `await`. To start a refresh from async code without waiting, use the completion form. A campaign that was switched off stays off for the rest of the screen visit, even after it is switched back on. To show it at once, end the visit after the refresh and bind again: cancel the decision’s `GronaSubscription`, or set the slot’s `screen` to `nil` and back. See [Screen visits](/mobile/ios/render-components/#screen-visits) for UIKit and SwiftUI code. ## Next steps [Section titled “Next steps”](#next-steps) * [Identify shoppers](/mobile/ios/identify-shoppers/) when they sign in. * [Set consent](/mobile/ios/consent/) from your consent flow. * [Push the basket and context](/mobile/ios/basket-and-context/).
# Install the SDK
> Add the Grona iOS SDK to your app with Swift Package Manager and check the requirements it sets.
This page adds the Grona iOS SDK to your app with Swift Package Manager. Do it once for the app target that shows Grona content. When the package resolves, go on to [initialise the SDK](/mobile/ios/initialize/). ## Requirements [Section titled “Requirements”](#requirements) | Requirement | Value | Notes | | ------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Minimum iOS version | iOS 16 | Your app’s deployment target must be iOS 16 or later. A target with a lower deployment target cannot link the package. | | Swift | 5.10 or later | Xcode 15.3 or later. The package also compiles in the Swift 6 language mode, and your app can use either mode. Apple’s own minimum for App Store uploads is higher: Xcode 26 or later since 28 April 2026. | | UI frameworks | UIKit and SwiftUI | `GronaSlotView` is a `UIView`. `GronaSlot` and the `.gronaDecision` modifier are for SwiftUI. | | Dependencies | None | The SDK uses only Apple frameworks. It brings no third-party code into your app. | | Info.plist keys | None | The SDK needs no usage descriptions and never asks for App Tracking Transparency. | | App extensions | Not supported | Call `Grona.start` from your app only. In an app extension it does nothing and the SDK stays inactive. | ## Add the package [Section titled “Add the package”](#add-the-package) The package is `https://github.com/GronaAI/grona-ios`. It has two products: | Product | Add it to | Purpose | | ------------ | ------------------------------------------ | ------------------------------------------------------------------------------------ | | `Grona` | Your app target | The SDK. | | `GronaDebug` | Your app target, used only in debug builds | A debug view for your own builds. See [Test and debug](/mobile/ios/test-and-debug/). | Before 1.0, a minor version can contain breaking changes. Pin the exact version, 0.1.0, and read the [changelog](/changelog/ios-sdk/) before you move to a newer one. * Xcode 1. In Xcode, choose **File > Add Package Dependencies**. 2. Paste `https://github.com/GronaAI/grona-ios` into the search field. 3. Set **Dependency Rule** to **Exact Version** and enter `0.1.0`. Click **Add Package**. 4. Add the `Grona` product to your app target. Add `GronaDebug` to the same target if you want the debug view. Click **Add Package**. 5. Build the app once. Xcode records the resolved revision in `Package.resolved`. Commit that file. * Package.swift If your app’s features live in a Swift package, add Grona to that package’s manifest: Package.swift
```swift
// swift-tools-version: 5.10
import PackageDescription
let package = Package(
name: "AcmeMarketFeatures",
platforms: [.iOS(.v16)],
products: [
.library(name: "AcmeMarketFeatures", targets: ["AcmeMarketFeatures"]),
],
dependencies: [
.package(url: "https://github.com/GronaAI/grona-ios", exact: "0.1.0"),
],
targets: [
.target(
name: "AcmeMarketFeatures",
dependencies: [
.product(name: "Grona", package: "grona-ios"),
.product(name: "GronaDebug", package: "grona-ios"),
]
),
]
)
```
The `package:` argument is the last component of the repository URL, `grona-ios`. Caution Swift Package Manager cannot link a product in Debug builds only. `GronaDebug` is therefore part of every build of the target you add it to. Import it and call it only inside `#if DEBUG`, as [Test and debug](/mobile/ios/test-and-debug/) shows. GronaDebug also checks for itself: it shows nothing unless it was compiled with the `DEBUG` condition. ## How the SDK is distributed [Section titled “How the SDK is distributed”](#how-the-sdk-is-distributed) The SDK ships as a source Swift package, so your security reviewers can read every line you build. There is no binary framework, CocoaPods pod or Carthage build. * **CocoaPods.** Grona does not publish a pod. CocoaPods’ central spec repository is scheduled to stop accepting new pods in December 2026. * **Signatures.** Apple requires signatures for binary copies of the SDKs on its list of commonly used third-party SDKs. Grona is a source package and is not on that list, so there is no signature to check. The exact revision pinned in `Package.resolved` is what guarantees you build the code you reviewed. ## What the SDK adds to your app [Section titled “What the SDK adds to your app”](#what-the-sdk-adds-to-your-app) * **No permissions or Info.plist keys.** The SDK never shows a system prompt. * **A privacy manifest.** The package includes a `PrivacyInfo.xcprivacy` file, which Xcode includes when it generates your app’s privacy report. See [App Store privacy and privacy manifest](/mobile/ios/privacy-manifest/). * **Network requests to known hosts only.** The SDK contacts the URLs in your options and the image hosts your workspace configuration allows, over https only. See [Domains and network calls](/get-started/domains-and-network-calls/). * **One folder of files.** The SDK keeps its state in its own folder inside your app’s Application Support directory, excluded from backups. See [Consent](/mobile/ios/consent/) for what it stores. The SDK never reads the advertising identifier (IDFA), the identifier for vendor (IDFV) or any other device identifier. ## Next steps [Section titled “Next steps”](#next-steps) * [Initialise the SDK](/mobile/ios/initialize/) when your app launches. * Read [Rendering modes](/mobile/concepts/rendering-modes/) to decide where Grona draws and where your app draws.
# Offers and grants
> Connect a grant source so money-bearing offers can render, then send their grant tokens with the order.
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”](#how-it-fits-together) 1. The shopper signs in and your app calls [`Grona.identify`](/mobile/ios/identify-shoppers/). 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](/mobile/backend/grants-and-orders/) and the [Mobile grants API](/api/mobile-grants/). Caution Your app never calls Grona’s grant endpoint and never holds the secret key. The grant endpoint takes your secret API key, which belongs on your server only. ## Implement the grant source [Section titled “Implement the grant source”](#implement-the-grant-source) `GronaGrantSource` has one async method. It returns the grants as compact JWS strings, or throws on failure. AcmeGrantSource.swift
```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. ## Configure the keys and the source [Section titled “Configure the keys and the source”](#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.
```swift
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](/mobile/backend/key-rotation/). ## When grants are fetched [Section titled “When grants are fetched”](#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 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. ## Send treatments with the order [Section titled “Send treatments with the order”](#send-treatments-with-the-order) At checkout, read the treatments the shopper can currently see and send their tokens to your order API.
```swift
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](/mobile/ios/preview-links/) 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”](#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. Danger Never ship `demoMode: true` in a production app. A real order backend must ignore any treatment without a valid token. ## Next steps [Section titled “Next steps”](#next-steps) * Set up [Grants and order verification](/mobile/backend/grants-and-orders/) on your server. * Read [Offers and grants](/mobile/concepts/offers-and-grants/) for how offers are analysed.
# Preview links
> Route Grona preview links to the SDK, by custom URL scheme or Universal Link, so testers can see one campaign variant before launch.
This page lets testers open a campaign variant on their own device before it goes live. A preview link carries a one-time pairing code. The SDK exchanges it for a signed pin bound to that install, so the tester sees the variant and nobody else’s experience changes. Your app routes every incoming link to `Grona.handle`. ## Link format [Section titled “Link format”](#link-format) Preview links use your app’s own URL scheme with the host `grona-preview`. If your app handles Universal Links, the SDK also accepts the same link on your own domain, with the path `/grona-preview`. | Link | Effect | | ------------------------------------------------------------ | --------------------------------------------------------------------------- | | `acmemarket://grona-preview?code=` | Pairs this install with one campaign variant. | | `acmemarket://grona-preview?clear=1` | Removes every preview pin on this device. | | `https://shop.example.com/grona-preview?code=` | The same pairing, delivered as a Universal Link. `?clear=1` works here too. | A code is 8 to 64 characters of letters, digits and hyphens. The SDK rejects a link with user info, a port, a fragment, an extra or repeated parameter, or a query longer than 256 characters. A custom-scheme link must have the host `grona-preview` and an empty path or `/`. An `https` link must have the path `/grona-preview` exactly. ## Handle the link [Section titled “Handle the link”](#handle-the-link) 1. Register your URL scheme. In Xcode, open your app target’s **Info** tab and add a URL type with your scheme, or add it to `Info.plist`: Info.plist
```xml
CFBundleURLTypes
CFBundleURLName
com.example.acmemarket
CFBundleURLSchemes
acmemarket
```
2. Pass every incoming URL to `Grona.handle` before your own routing. Cover every place a link arrives, including links that launch the app: * SwiftUI AcmeMarketApp.swift
```swift
import Grona
import SwiftUI
@main
struct AcmeMarketApp: App {
var body: some Scene {
WindowGroup {
HomeView()
.onOpenURL { url in
if Grona.handle(url) { return } // a Grona preview link, accepted or not
AcmeRouter.shared.open(url) // your own deep-link routing
}
}
}
}
```
`onOpenURL` receives custom-scheme links and Universal Links, including the link that launched the app. * UIKit SceneDelegate.swift
```swift
import Grona
import UIKit
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
// ... set up the window, then handle a link that launched the app
connectionOptions.urlContexts.forEach { open($0.url) }
connectionOptions.userActivities.compactMap(\.webpageURL).forEach { open($0) }
}
// Custom-scheme links while the app runs
func scene(_ scene: UIScene, openURLContexts URLContexts: Set) {
URLContexts.forEach { open($0.url) }
}
// Universal Links while the app runs
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
if let url = userActivity.webpageURL { open(url) }
}
private func open(_ url: URL) {
if Grona.handle(url) { return } // a Grona preview link, accepted or not
AcmeRouter.shared.open(url) // your own deep-link routing
}
}
```
An app without scenes routes the same links from `application(_:open:options:)` and `application(_:continue:restorationHandler:)` in its app delegate. 3. Set a `previewURL` in your [options](/mobile/ios/initialize/#options). Grona provides it for your workspace. Without it the SDK ignores codes and can only clear pins. `Grona.handle` is safe from any thread and returns at once; pairing runs in the background. It returns `true` for any Grona preview link, whether or not the SDK accepted it: a link whose host is `grona-preview`, or an `https` link whose path is `/grona-preview`. It returns `false` for every other link. A rejected link is logged through your `GronaLogger`. Caution A pairing code works like a password: whoever opens the link gets a pin on their own install. Share preview links only with testers. Codes are single use and expire within minutes. ## Use Universal Links for preview [Section titled “Use Universal Links for preview”](#use-universal-links-for-preview) Any app can register the same custom URL scheme, and iOS does not define which app opens a link when two apps claim it. A Universal Link opens only your app, because your own domain names it. If your app already handles Universal Links, you can deliver preview codes that way: 1. Add the **Associated Domains** capability to your app target with `applinks:shop.example.com`. 2. Add a component for the preview path to the `apple-app-site-association` file your domain serves: https\://shop.example.com/.well-known/apple-app-site-association
```json
{
"applinks": {
"details": [
{
"appIDs": ["ABCDE12345.com.example.acmemarket"],
"components": [
{ "/": "/grona-preview", "comment": "Grona preview links" }
]
}
]
}
}
```
3. Send testers the code on your domain: `https://shop.example.com/grona-preview?code=`. The handlers above already route it to `Grona.handle`. Grona’s dashboard shows the custom-scheme link, so build the https link yourself by replacing `acmemarket://grona-preview` with `https://shop.example.com/grona-preview`. Apple caches `apple-app-site-association` files, so a new component can take time to reach devices. A Universal Link tapped in Safari on a page of the same domain opens in Safari, not in your app. ## Pairing [Section titled “Pairing”](#pairing) When a code arrives, the SDK posts the code, the install id and the workspace key over https to `previewURL`, and stores the signed pin it gets back. * Pairing is refused while consent is `.denied`, because the request carries the install id. Under `.unknown` and `.granted` it runs: pairing is a tester action, not analytics. * One exchange runs at a time. A link that arrives while one is in flight is ignored. * Any response other than a valid pin leaves existing pins unchanged. ## How a pin behaves [Section titled “How a pin behaves”](#how-a-pin-behaves) * **Lifetime.** A pin lasts until it expires (at most 24 hours), until a `clear=1` link, until `Grona.reset()`, or until consent becomes `.denied`. It survives app restarts and is verified again on every load. * **One per campaign.** The newest pin for a campaign wins. * **What it bypasses.** The pinned campaign goes first in its placement and skips audience rules, holdouts, the traffic split and activation. * **What it never bypasses.** A campaign disabled in the configuration (`enabled: false`), an expired configuration, or validation. A customer-unit campaign, such as every offer, still shows only to an identified shopper. * **Control variants.** A pinned control variant renders nothing, as any control arm does. * **Marking.** SDK components draw a “Preview” chip, decisions carry `isPreview == true`, and every event carries a preview flag so analysis excludes it. * **No grants.** A pinned offer renders without a grant, but it never appears in `Grona.activeTreatments()`. Pins never produce a grant, so a preview can never change what an order costs. Debug builds can also pin a variant locally from the [debug view](/mobile/ios/test-and-debug/), without a link. Local pins are unsigned, last at most 24 hours, and are ignored in builds compiled without `DEBUG`. ## Next steps [Section titled “Next steps”](#next-steps) * [Test and debug](/mobile/ios/test-and-debug/) your integration.
# App Store privacy and privacy manifest
> What the Grona iOS SDK's privacy manifest declares, and how to carry it into your App Store privacy details.
This page helps your app team answer Apple’s privacy questions for the Grona SDK. Apple holds your app responsible for the data that the SDKs inside it collect, so Grona’s data belongs in your App Store privacy details. The SDK ships its own privacy manifest. This page explains what it declares and why, and how to carry it into the answers you give in App Store Connect. Caution The final answers are yours. Your App Store privacy details depend on how you configure Grona, what your consent text says, your contract with Grona and the rest of your app. Review them with your privacy counsel. ## When the SDK sends anything [Section titled “When the SDK sends anything”](#when-the-sdk-sends-anything) Apple counts data as collected when it is transmitted off the device. The Grona SDK sends events only when both of these hold: * You set an `eventsURL` in your options. Without one, the SDK sends no events and keeps no queue. * The shopper’s consent is `.granted`. Under `.unknown` events stay in memory and none are sent. Under `.denied` nothing is collected. See [Consent](/mobile/ios/consent/). If you turn on `sendPreConsentEvents`, events buffered under `.unknown` are sent once consent is granted, so your consent text must cover them. Three other requests leave the device: * **The configuration fetch** carries no identifiers in any consent state. * **Preview pairing** sends the install id when a tester opens a [preview link](/mobile/ios/preview-links/). It is refused under `.denied`. * **Image downloads** fetch component images from the image hosts in your signed configuration. They carry no Grona identifiers, but the image host sees the device’s IP address. Asking for consent does not remove the need to declare. Apple asks you to declare data that you collect on an ongoing basis after the shopper agrees. ## The SDK’s privacy manifest [Section titled “The SDK’s privacy manifest”](#the-sdks-privacy-manifest) The `Grona` target includes `PrivacyInfo.xcprivacy` as a processed resource. Xcode reads it when you generate your app’s privacy report. It declares: | Key | Value | | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `NSPrivacyTracking` | `false` | | `NSPrivacyTrackingDomains` | Not present. The SDK contacts no tracking domains. | | `NSPrivacyCollectedDataTypes` | Six data types, all linked to the shopper and none used for tracking. See [Data types](#data-types). | | `NSPrivacyAccessedAPITypes` | Two required-reason APIs: File timestamp, reason `C617.1`, and System boot time, reason `35F9.1`. See [Required-reason APIs](#required-reason-apis). | The file that ships in the 0.1.0 package: PrivacyInfo.xcprivacy
```xml
NSPrivacyTracking
NSPrivacyCollectedDataTypes
NSPrivacyCollectedDataType
NSPrivacyCollectedDataTypeDeviceID
NSPrivacyCollectedDataTypeLinked
NSPrivacyCollectedDataTypeTracking
NSPrivacyCollectedDataTypePurposes
NSPrivacyCollectedDataTypePurposeAnalytics
NSPrivacyCollectedDataTypePurposeProductPersonalization
NSPrivacyCollectedDataTypePurposeAppFunctionality
NSPrivacyCollectedDataTypePurposeDeveloperAdvertising
NSPrivacyCollectedDataType
NSPrivacyCollectedDataTypeUserID
NSPrivacyCollectedDataTypeLinked
NSPrivacyCollectedDataTypeTracking
NSPrivacyCollectedDataTypePurposes
NSPrivacyCollectedDataTypePurposeAnalytics
NSPrivacyCollectedDataTypePurposeProductPersonalization
NSPrivacyCollectedDataTypePurposeAppFunctionality
NSPrivacyCollectedDataTypePurposeDeveloperAdvertising
NSPrivacyCollectedDataType
NSPrivacyCollectedDataTypeProductInteraction
NSPrivacyCollectedDataTypeLinked
NSPrivacyCollectedDataTypeTracking
NSPrivacyCollectedDataTypePurposes
NSPrivacyCollectedDataTypePurposeAnalytics
NSPrivacyCollectedDataTypePurposeProductPersonalization
NSPrivacyCollectedDataTypePurposeDeveloperAdvertising
NSPrivacyCollectedDataType
NSPrivacyCollectedDataTypeAdvertisingData
NSPrivacyCollectedDataTypeLinked
NSPrivacyCollectedDataTypeTracking
NSPrivacyCollectedDataTypePurposes
NSPrivacyCollectedDataTypePurposeAnalytics
NSPrivacyCollectedDataTypePurposeProductPersonalization
NSPrivacyCollectedDataTypePurposeDeveloperAdvertising
NSPrivacyCollectedDataType
NSPrivacyCollectedDataTypePurchaseHistory
NSPrivacyCollectedDataTypeLinked
NSPrivacyCollectedDataTypeTracking
NSPrivacyCollectedDataTypePurposes
NSPrivacyCollectedDataTypePurposeAnalytics
NSPrivacyCollectedDataTypePurposeProductPersonalization
NSPrivacyCollectedDataTypePurposeDeveloperAdvertising
NSPrivacyCollectedDataType
NSPrivacyCollectedDataTypeOtherDiagnosticData
NSPrivacyCollectedDataTypeLinked
NSPrivacyCollectedDataTypeTracking
NSPrivacyCollectedDataTypePurposes
NSPrivacyCollectedDataTypePurposeAnalytics
NSPrivacyCollectedDataTypePurposeAppFunctionality
NSPrivacyAccessedAPITypes
NSPrivacyAccessedAPIType
NSPrivacyAccessedAPICategoryFileTimestamp
NSPrivacyAccessedAPITypeReasons
C617.1
NSPrivacyAccessedAPIType
NSPrivacyAccessedAPICategorySystemBootTime
NSPrivacyAccessedAPITypeReasons
35F9.1
```
## Data types [Section titled “Data types”](#data-types) With consent granted and an events endpoint set, the SDK sends the following. Each row names the App Store data type it matches. | App Store category and data type | What Grona sends | Purposes declared | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | Identifiers: Device ID | The SDK’s install id, a random UUID the SDK generates, and a random session id | Analytics, Product Personalization, App Functionality, Developer’s Advertising or Marketing | | Identifiers: User ID | The customer id you pass to `Grona.identify`, only while a shopper is identified | Analytics, Product Personalization, App Functionality, Developer’s Advertising or Marketing | | Usage Data: Product Interaction | Campaign events (eligible, visible, served, exposure, taps on Grona content, dismissals), product views, cart adds and removals | Analytics, Product Personalization, Developer’s Advertising or Marketing | | Usage Data: Advertising Data | Which Grona content was served and seen | Analytics, Product Personalization, Developer’s Advertising or Marketing | | Purchases: Purchase History | The purchase event (order id, order total, currency, the Grona treatments, and the products in the order that a Grona campaign showed or added) and the checkout total | Analytics, Product Personalization, Developer’s Advertising or Marketing | | Diagnostics: Other Diagnostic Data | The SDK version, your app’s version, the platform and the iOS version, on every batch of events | Analytics, App Functionality | Product ids and prices travel only for products a Grona campaign showed or added in the session. Other products are counted without them, and the full basket never leaves the device. Exception: on keyed placements such as `cart_line`, campaign events carry the line’s product id as their `key`, in every arm. So while a `cart_line` campaign is live, the product ids of the cart lines bound to it leave the device. * **Why everything is linked.** Every event carries the install id, and the customer id while a shopper is identified. Data sent with an identifier is linked to the shopper. * **Why App Functionality.** Grona assigns shoppers to variants from the install id and the customer id, and checks SDK versions against the configuration. * **Why Advertising Data and Developer’s Advertising or Marketing.** Grona’s banners and offers promote your products to targeted shoppers, and Grona measures who saw them. That is the plain reading of both of Apple’s definitions, so the manifest declares them. If your team concludes they do not apply to how you use Grona, your privacy details are your decision. Xcode’s privacy report still lists them, because the report reads the SDK’s manifest. If you drop them, drop Advertising or marketing from your [Play answers](/mobile/android/play-data-safety/) too, so both stores tell the same story. The SDK does not send: * location, contacts, photos, files or any other device content; * names, email addresses or phone numbers; * search text or browsing history; * the shopper’s profile from `Grona.setProfile`, which stays on the device; * the full basket from `Grona.setBasket`, which stays on the device. Only the product ids of cart lines bound to a keyed placement such as `cart_line` leave it, as campaign event keys. The SDK does not read or send location. Grant fetching runs through your own `GronaGrantSource` and your own backend. Declare that data flow as part of your app, as you would any call to your own servers. ### IP addresses [Section titled “IP addresses”](#ip-addresses) Every request reaches Grona’s servers from the device’s IP address. Grona truncates IP addresses when it receives them and does not derive location from them, so the SDK adds no location entry to your answers. Caution The customer id must be your opaque internal id. If your app passes an email address, a phone number or a hash of either to `Grona.identify`, you collect Email Address or Phone Number as well, and the SDK’s manifest no longer describes what your app sends. ## Required-reason APIs [Section titled “Required-reason APIs”](#required-reason-apis) Apple requires every SDK to declare the required-reason APIs its own code calls. The Grona SDK declares two categories. | Category | Reason | Why the SDK needs it | | --------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | File timestamp (`NSPrivacyAccessedAPICategoryFileTimestamp`) | `C617.1` | The SDK keeps its event queue and state in its own files inside your app’s container, and may read the size and dates of those files to bound its storage. It reads no other file’s metadata, and nothing read this way leaves the device. | | System boot time (`NSPrivacyAccessedAPICategorySystemBootTime`) | `35F9.1` | The SDK reads the system uptime clock to measure time between events inside your app and to run its timers: the one-second visibility dwell, the two-second visit grace period, and back-off. The uptime clock does not move when the shopper changes the device’s date and time. Neither the boot time nor anything derived from it leaves the device. | The SDK uses no other required-reason category. It stores everything in its own files, so it does not use `UserDefaults`. It never checks free disk space, and it never reads the list of active keyboards. ## Tracking and App Tracking Transparency [Section titled “Tracking and App Tracking Transparency”](#tracking-and-app-tracking-transparency) The manifest sets `NSPrivacyTracking` to `false` and lists no tracking domains. The SDK never calls App Tracking Transparency, so your app needs no `NSUserTrackingUsageDescription` for Grona. Apple defines tracking as linking data from your app with data from other companies’ apps, websites or offline properties for targeted advertising or advertising measurement, or sharing data with data brokers. The SDK’s answer of no tracking holds while all of these are true: * The SDK reads no advertising identifier and no other device identifier, and does no fingerprinting. This is how the SDK is built. * Grona uses your app’s data only for your app’s analytics and personalisation, as your service provider. It does not combine your shoppers’ data with other Grona customers’ data to target or measure campaigns, and does not share it with data brokers or advertising networks or export audiences from it. Confirm this in your contract with Grona. * Your app does not send Grona’s ids or events to advertising platforms, data brokers or retail media networks, and does not join them with other companies’ data for advertising. If your app sends Grona’s customer id or purchase events to an advertising platform, your app is tracking. It must then ask for permission with App Tracking Transparency, and your privacy details must mark that data as used to track the shopper, whatever the SDK’s manifest says. ## Fill in your App Store privacy details [Section titled “Fill in your App Store privacy details”](#fill-in-your-app-store-privacy-details) 1. **Generate the privacy report.** Archive your app, then in Xcode’s Organizer choose **Generate Privacy Report**. The report combines your app’s privacy manifest with the manifests of the SDKs it links, including Grona’s. 2. **Enter the answers in App Store Connect.** Open your app’s **App Privacy** page and add each data type from the report, with your app’s own data. Grona’s data types all go under **Data Linked to You**, with the purposes in the table above. 3. **Keep consent in mind.** Declare the data types even if every shopper is asked for consent first. 4. **Review after each SDK update.** The [iOS SDK changelog](/changelog/ios-sdk/) notes every change to the manifest. You can update your answers in App Store Connect without a new app release. ## Deletion [Section titled “Deletion”](#deletion) * **On the device.** `Grona.reset()` clears the event queue, the customer id, the profile, grants, the basket snapshot and preview pins, and issues a new install id. Call it from your account deletion or erasure flow. See [Identify shoppers](/mobile/ios/identify-shoppers/#log-out). * **Already sent.** To delete data Grona already holds for a customer id or install id, contact [Grona support](/get-started/support/). ## Next steps [Section titled “Next steps”](#next-steps) * Read [Consent and data minimisation](/mobile/concepts/consent/). * Read [Data, privacy and security](/get-started/data-privacy-security/).
# Render Grona components
> Place GronaSlotView or GronaSlot in your screens so Grona can show banners, progress bars, offers and messages.
This page places Grona’s own components in your screens. You add a slot at each placement once, ship the release, and from then on Grona’s configuration decides what fills it, for whom and against which control group. In UIKit the slot is `GronaSlotView`; in SwiftUI it is `GronaSlot`. If you want to draw the content with your own product views instead, see [Host-rendered decisions](/mobile/ios/host-rendered-decisions/). A slot renders four component types: `banner`, `progress`, `offer` and `message`. For what each one looks like, see [Grona components](/mobile/concepts/components/). ## Add a slot [Section titled “Add a slot”](#add-a-slot) * UIKit 1. Create a `GronaSlotView` with its placement and add it to your layout. Pin its leading, trailing and top edges, and give it no height constraint: the slot sets its own height. 2. Set the slot’s `screen` when the screen loads. Setting it starts the slot. HomeViewController.swift
```swift
import Grona
import UIKit
final class HomeViewController: UIViewController {
private let homeTopSlot = GronaSlotView(placement: "home_top")
private let stack = UIStackView()
override func viewDidLoad() {
super.viewDidLoad()
stack.axis = .vertical
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
stack.addArrangedSubview(homeTopSlot)
stack.setCustomSpacing(0, after: homeTopSlot) // no gap under an empty slot
stack.addArrangedSubview(FeaturedRowView()) // your own content
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
stack.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
])
homeTopSlot.screen = .home
}
}
```
3. Run the app. With nothing to show, the slot is laid out at zero height. When a campaign targets the placement, its component appears in the slot. In a storyboard, set the view’s class to `GronaSlotView` and its module to `Grona`, set **Placement** in the Attributes inspector, then set `screen` from code. If you create the slot with `init(frame:)`, set `placement` before `screen`, because setting `screen` is what binds the slot. * SwiftUI 1. Add a `GronaSlot` with its placement and the current screen. It starts when it appears. HomeView\.swift
```swift
import Grona
import SwiftUI
struct HomeView: View {
var body: some View {
ScrollView {
VStack(spacing: 0) {
GronaSlot(placement: "home_top", screen: .home)
FeaturedRow() // your own content
}
.padding(.horizontal, 16)
}
}
}
```
2. Run the app. With nothing to show, the slot is laid out at zero height. When a campaign targets the placement, its component appears in the slot, and the slot grows to fit it. `GronaSlot` wraps a `GronaSlotView`, so everything on this page applies to both. ## Keep the slot visible [Section titled “Keep the slot visible”](#keep-the-slot-visible) A slot with nothing to show stays visible at zero height, so the control arm is measured on the same view as the treatment arm. Grona logs `anchor_visible` for every arm once the slot’s top edge has been on screen for one second. * Never hide a slot: no `isHidden = true`, no `alpha` of 0, and in SwiftUI no `.hidden()` or `.opacity(0)`. Do not wrap a `GronaSlot` in an `if` that removes it when you expect it to be empty. A hidden or removed slot is never measured, so only the treatment arm would be counted. In UIKit, debug builds log a warning once per slot that is hidden or fully transparent. * Give the slot no height constraint and no padding of its own. A height constraint or padding on the slot stops it collapsing to zero height when empty. * Watch stack spacing. `UIStackView` drops its spacing only next to hidden views, so a visible zero-height slot still gets the spacing on both sides. Use `setCustomSpacing(0, after:)` next to the slot, or `VStack(spacing: 0)` in SwiftUI, and put spacing inside your own content. For why this matters, see [Events and exposure](/mobile/concepts/events-and-exposure/). ## Placements [Section titled “Placements”](#placements) The placement set is fixed. Use these names exactly. | Placement | Where it goes | | -------------------- | ----------------------------------------------------------------------------------------------- | | `home_top` | Top of the home screen | | `home_feed` | In the home feed | | `plp_inline` | Inline in a product listing | | `pdp_below_buy` | On the product page, below the buy button | | `cart_line` | On a cart line. Keyed by the line’s product id, and the only placement for substitute decisions | | `cart_top` | Top of the cart | | `cart_below_lines` | In the cart, below the lines | | `checkout_above_pay` | On checkout, above the pay button | | `order_confirmation` | On the order confirmation screen | | `membership_top` | Top of the membership screen | A configuration that names any other placement is rejected for that campaign. For guidance on choosing placements, see [Placements](/mobile/concepts/placements/). ## Screens [Section titled “Screens”](#screens) `GronaScreen` tells Grona where the shopper is. Rules read it through `screen.type`, `screen.product`, `screen.category` and `screen.treatments`. | Value | Use it for | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `.home` | Home | | `.categories` | The category list | | `.search` | Search results | | `.cart` | The cart | | `.checkout` | Checkout | | `.category(_:)` | One category’s product listing, for example `.category("dairy")` | | `.product(_:)` | One product page, for example `.product("sku_10432")` | | `.orderConfirmation(treatmentTypes:)` | The confirmation screen. Pass the treatment types your order backend actually applied, not the ones the app showed | | `.other(_:)` | Any other screen, by your own name, for example `.other("membership")` | `GronaScreen` is `Hashable`. Two screens are equal when their type and values are equal, so `.product("sku_10432")` set twice is the same screen. ## Screen visits [Section titled “Screen visits”](#screen-visits) Each placement on a screen counts as one visit. Grona logs `eligible`, `anchor_visible`, `exposure` and `served` at most once per visit, which keeps counts honest when views are reused. * Setting `screen` starts a visit. Setting an equal value again continues it. * Setting `screen` to `nil` ends the visit and empties the slot. In SwiftUI, pass `nil` as the screen. * A slot that leaves its window and comes back, for example under a pushed screen or in another tab, keeps its visit however long it was away. * Another slot that binds the same placement, screen and key, such as one in a reused cell, joins the visit while a slot for it is in a window, or within 2 seconds after the last one left. * Switching a campaign off applies at once, including to content on screen. Every other configuration change, including a campaign switched back on, waits for the next visit. A screen that stays alive, such as a tab’s root screen, keeps its visit, so a campaign that starts or is switched back on later appears there only after the visit ends. To show changes, end the visit when the screen reappears: set `screen` to `nil` and back, or cancel the decision’s subscription and request again. Each new visit logs `eligible` again. To start a fresh visit, for example after [`Grona.refresh`](/mobile/ios/initialize/#refresh-on-demand) brings a campaign back, end the visit and bind again: * UIKit
```swift
Grona.refresh { [weak self] in
guard let self else { return }
cartTopSlot.screen = nil // ends the visit
cartTopSlot.screen = .cart // starts a new one
}
```
* SwiftUI SwiftUI merges state changes made in the same update, so the `nil` must reach the slot in an update of its own. Set it to `nil`, then set the screen again from `.task(id:)`, which runs after the update:
```swift
import Grona
import SwiftUI
struct CartView: View {
@State private var screen: GronaScreen? = .cart
var body: some View {
ScrollView {
VStack(spacing: 0) {
GronaSlot(placement: "cart_top", screen: screen)
CartLinesView() // your own content
}
}
.refreshable {
await Grona.refresh()
screen = nil // ends the visit
}
.task(id: screen) {
if screen == nil { screen = .cart } // starts a new one
}
}
}
```
## Slots in table and collection views [Section titled “Slots in table and collection views”](#slots-in-table-and-collection-views) A slot can sit in a cell, such as a promotional row in a product listing. Add it to the cell’s content view once, and set `screen` each time the cell is configured.
```swift
import Grona
import UIKit
final class PromoCell: UICollectionViewCell {
let slot = GronaSlotView(placement: "plp_inline")
override init(frame: CGRect) {
super.init(frame: frame)
slot.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(slot)
NSLayoutConstraint.activate([
slot.topAnchor.constraint(equalTo: contentView.topAnchor),
slot.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
slot.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
slot.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) is not used")
}
}
// When you configure the cell
cell.slot.screen = .category(categoryID)
```
Content often arrives after the cell is laid out. The slot then changes its height through Auto Layout, so let the list resize the cell: set `selfSizingInvalidation` to `.enabledIncludingConstraints` on the collection or table view, and use self-sizing cells.
```swift
collectionView.selfSizingInvalidation = .enabledIncludingConstraints
```
The visit is keyed on placement, screen and key, not on the view. Configuring a cell with the same placement and screen continues the visit while any slot for it is in a window, and for 2 seconds after the last one left. So a row that is reused across cells logs its events once. A row that comes back in a different cell more than 2 seconds after it left starts a new visit. A slot that leaves the window and comes back keeps its visit. In SwiftUI, a `GronaSlot` in a `List` or `LazyVStack` row needs no extra setup: it reports its new height when content arrives, and the row resizes. ## How visibility is measured [Section titled “How visibility is measured”](#how-visibility-is-measured) The SDK measures the slot’s top edge in its window. The top edge counts as visible when it lies inside the window and inside every ancestor view that clips its subviews, such as a scroll view, a table view or a collection view. It must stay visible for one continuous second while: * the app is in the foreground and active; * the slot’s window is the key window; * no view controller is presented over the slot’s screen: an alert, a sheet or a full-screen cover stops the second; * neither the slot nor any of its ancestors is hidden or fully transparent. A zero-height slot counts when its top edge is inside those bounds, whatever its width. ## Visibility and limits [Section titled “Visibility and limits”](#visibility-and-limits) * A slot counts as live while it is in a window, neither it nor an ancestor is hidden, and the app is not in the background. An app that is only inactive, for example under a system sheet, still counts. A slot that has left the window, such as one under a pushed view controller or in an unselected tab, is not live. * Your workspace configuration caps the number of components on one screen (3 unless the configuration sets another number). Slots whose `GronaScreen` values are equal count as one screen. Lower-priority campaigns yield when the cap is reached. Only live slots count toward it. * Images come from your configuration as `asset://img/` paths, which load from the `img` folder in your app’s main bundle, or as `https` URLs on hosts your configuration allows. Add bundled images in a folder named `img` as a folder reference, so Xcode keeps the folder in the bundle. Anything else is dropped and the component renders without it. ## Taps and actions [Section titled “Taps and actions”](#taps-and-actions) A banner’s call to action can add products to the cart or open a screen. The SDK runs it only from the shopper’s tap, validates it, and then calls your `GronaHost`. See [Handle actions](/mobile/ios/handle-actions/). ## Next steps [Section titled “Next steps”](#next-steps) * [Handle actions](/mobile/ios/handle-actions/) from Grona components. * [Match components to your app](/mobile/ios/theming/). * [Render decisions with your own views](/mobile/ios/host-rendered-decisions/).
# Test and debug
> Inspect SDK state in the GronaDebug view, route SDK logs to the unified logging system and catch common integration mistakes.
This page shows what the SDK is doing on a test device. The `GronaDebug` product adds a screen that lists the active configuration, every campaign and why it is live or not, bound slots and recent events. A logger routes SDK warnings to Xcode’s console. Both are meant for debug builds only. ## Open the debug view [Section titled “Open the debug view”](#open-the-debug-view) 1. Add the `GronaDebug` product to your app target, as on [Install the SDK](/mobile/ios/install/). 2. Import it and call it only inside `#if DEBUG`, from a hidden menu or a developer setting: * UIKit DeveloperMenuViewController.swift
```swift
#if DEBUG
import Grona
import GronaDebug
import UIKit
final class DeveloperMenuViewController: UIViewController {
@objc func showGronaDebugView() {
Grona.presentDebugView(from: self)
}
}
#endif
```
* SwiftUI GronaDebugButton.swift
```swift
#if DEBUG
import GronaDebug
import SwiftUI
struct GronaDebugButton: View {
@State private var isPresented = false
var body: some View {
Button("Grona debug view") { isPresented = true }
.sheet(isPresented: $isPresented) { GronaDebugView() }
}
}
#endif
```
3. Tap **Refresh** on the debug screen after you change something in the app. ### What keeps it out of release builds [Section titled “What keeps it out of release builds”](#what-keeps-it-out-of-release-builds) Swift Package Manager cannot link `GronaDebug` in Debug builds only, so two guards keep the debug view out of the app you ship: * **Your `#if DEBUG` guards.** With the import and every call inside `#if DEBUG`, your release build contains no code that opens the view. Xcode’s app templates set the `DEBUG` condition in the Debug configuration only. * **GronaDebug’s own check.** GronaDebug draws its view only when it was itself compiled with the `DEBUG` condition. Otherwise `presentDebugView(from:)` does nothing and logs a warning, and `GronaDebugView` is empty. The debug view has no URL route, so nothing outside your own code can open it. ## What the debug view shows [Section titled “What the debug view shows”](#what-the-debug-view-shows) | Section | Contents | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | SDK | SDK version, workspace, configuration source (`bundled`, `cache` or `remote`), configuration version, key id, expiry, last configuration error, consent state, the last 4 characters of the install id, whether a shopper is identified, and whether demo mode is in effect | | Campaigns | Each campaign’s name, id and placement, whether it would render now for this shopper, and if not, why (for example `activation off`, `holdout`, `no grant` or `audience: profile unknown`). Also the assigned arm and variant | | Pins | Local pins, with a field to pin a variant on this device and a button to clear pins | | Slots | Every bound slot and decision with its placement, screen and key, and the campaign it shows | | Recent events | Up to 200 recent events, newest first, with identifiers shortened | Local pins work only in debug builds and never create a grant. The recent-events list is kept only when the SDK is compiled with `DEBUG`, so release builds keep no copy of order ids, totals or product ids for it. ## Route logs to the console [Section titled “Route logs to the console”](#route-logs-to-the-console) `GronaLogger` receives SDK diagnostics. Without one the SDK is silent. Send them to the unified logging system in debug builds: AcmeGronaLogger.swift
```swift
import Grona
import OSLog
struct AcmeGronaLogger: GronaLogger {
func log(_ level: GronaLogLevel, _ message: String) {
let logger = Logger(subsystem: "com.example.acmemarket", category: "Grona")
switch level {
case .debug: logger.debug("\(message, privacy: .public)")
case .info: logger.info("\(message, privacy: .public)")
case .warning: logger.warning("\(message, privacy: .public)")
case .error: logger.error("\(message, privacy: .public)")
}
}
}
```
Pass it in your options in debug builds only:
```swift
#if DEBUG
let gronaLogger: (any GronaLogger)? = AcmeGronaLogger()
#else
let gronaLogger: (any GronaLogger)? = nil
#endif
let options = try GronaOptions(
workspaceKey: "acme-market",
configKeys: ["acme-market-2026-10": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE..."],
logger: gronaLogger
)
```
`GronaLogLevel` has four levels: `.debug`, `.info`, `.warning` and `.error`. Messages start with `Grona:`. The SDK never passes customer ids, basket contents, tokens or keys to the logger, so marking the message public is safe. The protocol requires `Sendable`: the SDK calls your logger off the main actor, so keep it thread-safe and away from your views. ## Warnings to look for [Section titled “Warnings to look for”](#warnings-to-look-for) | Warning | What to fix | | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `the anchor is hidden` or `... is fully transparent` | A UIKit slot or decision anchor is hidden or has an alpha of 0. Keep it visible at zero height when empty, or the control arm is never measured. Logged once per anchor, in debug builds only. The SDK cannot detect `.hidden()` or `.opacity(0)` in SwiftUI, so check those yourself. | | `identify ignored: customer id must match ...` | The customer id has characters outside `[A-Za-z0-9._:-]` or is longer than 128. | | `identify: an all-digit id of 9 or more characters ...` | The id looks like a phone number or national id. Pass an opaque internal id. | | `profile ignored: it is not bound to the identified customer` | `setProfile` came before `identify`, or for another customer. | | `preview link rejected: ...` | The preview link is malformed. See [Preview links](/mobile/ios/preview-links/#link-format). | | `start ignored: Grona does not run in app extensions` | `Grona.start` ran in an app extension. | | `presentDebugView ignored: GronaDebug was compiled without DEBUG` | The debug view was called from a build that compiles packages without `DEBUG`. | ## Check the integration [Section titled “Check the integration”](#check-the-integration) * **Empty slots.** In Xcode’s view debugger, an empty slot is visible with zero height. No hidden-anchor warning appears. * **Campaign state.** Each campaign you expect is live in the debug view, or shows a reason you understand. * **Basket.** Progress bars and substitutes follow the cart as you change it. If not, a cart path is missing `setBasket`. * **Consent.** Under `.unknown` no events are sent. After `.granted`, events reach your events endpoint. * **Links.** A preview link opens the pinned variant both when the app is running and when the link launches it. * **Release build.** Archive a release build: it compiles with your `#if DEBUG` code removed, and **Generate Privacy Report** in the Organizer lists Grona’s privacy manifest. To pick up a configuration change during a test without waiting, call `Grona.refresh` with a completion. See [Refresh on demand](/mobile/ios/initialize/#refresh-on-demand). To see one variant, use a [preview link](/mobile/ios/preview-links/) or a local pin. ## Next steps [Section titled “Next steps”](#next-steps) * [Theming](/mobile/ios/theming/) for how components look. * [App Store privacy and privacy manifest](/mobile/ios/privacy-manifest/) before you release.
# Theming
> Set the fonts, colours and corner radius Grona components use, and see what the iOS components do for accessibility.
This page matches Grona’s components to your app and describes their accessibility on iOS. You pass a `GronaTheme` in your options once, at launch. Every banner, progress bar, offer and message then uses your fonts, colours and corner radius. Decisions you render yourself are not affected, because your own views draw them. ## Set a theme [Section titled “Set a theme”](#set-a-theme) AcmeTheme.swift
```swift
import Grona
import UIKit
let acmeTheme = GronaTheme(
regularFont: UIFont(name: "AcmeSans-Regular", size: 15),
boldFont: UIFont(name: "AcmeSans-Bold", size: 15),
primary: UIColor(red: 0.04, green: 0.25, blue: 0.55, alpha: 1),
onPrimary: .white,
accent: UIColor(red: 1.00, green: 0.76, blue: 0.20, alpha: 1),
cornerRadius: 12
)
```
Pass it as `theme: acmeTheme` in your [`GronaOptions`](/mobile/ios/initialize/#options). Every parameter is optional. ## Theme tokens [Section titled “Theme tokens”](#theme-tokens) | Parameter | Default | Used for | | -------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | `regularFont` | `nil`, the system font | Body text. Grona takes the typeface from the font and sets each text size itself. | | `boldFont` | `nil`, the bold version of the regular font | Titles, labels and buttons | | `primary` | `#054B3A` | Banner background, the progress bar’s fill before a tier is met, and icon colour in messages | | `onPrimary` | `#FFFFFF` | Text on banners | | `accent` | `#6FCF97` | Banner accents, the progress bar’s fill once a tier is met, and the tint of offers and message icons | | `surface` | `#FFFFFF` | Background of progress bars, and the base of offer and message backgrounds | | `text` | `#1A1C1A` | Body text on progress bars, offers and messages | | `mutedText` | `#6B706C` | Secondary text, such as footnotes | | `cornerRadius` | `14` | Corner radius of component cards, in points. Values are kept between 0 and 32. | ## How campaign styles combine with your theme [Section titled “How campaign styles combine with your theme”](#how-campaign-styles-combine-with-your-theme) A campaign can set its own `background`, `text` and `accent` colours for one component, for example a weekend breakfast banner in a seasonal colour. Those override your theme for that component only. Everything else comes from your theme. Whatever the source, the SDK checks text against its background. When the preferred text colour does not reach WCAG AA contrast on that background, the SDK uses white or near-black text, whichever reads better. ## Light and dark appearance [Section titled “Light and dark appearance”](#light-and-dark-appearance) The theme is fixed for the life of the process. Grona resolves each colour once, the first time it draws a component, in the appearance of that slot, and keeps the result. A colour with a dark variant, such as an asset catalogue colour, therefore follows the appearance your app has when Grona first draws. It does not change if the shopper switches appearance while the app runs. Choose colours that read in both appearances, as [Theming and accessibility](/mobile/concepts/theming-and-accessibility/) advises. ## Accessibility [Section titled “Accessibility”](#accessibility) This is what the iOS components do in this version. * **Readable contrast.** Before drawing, the SDK checks each text colour against its background. If titles, body text or button labels would fall below a contrast ratio of 4.5:1 (WCAG AA), it switches them to white or near-black, whichever reads better. Eyebrows and progress footnotes use a 3:1 threshold. This applies to your theme and to per-campaign colours. * **Dynamic Type.** Text follows the shopper’s text size setting. The SDK scales your fonts with `UIFontMetrics` and updates them when the setting changes. * **Line limits.** Each text field has a line limit, the same as on Android, for example three lines for a banner title and four for body text. Text that runs over ends with an ellipsis, so at the largest text sizes long copy is cut off. * **Touch targets.** The banner’s call-to-action button has a touch target of at least 44 by 44 points. * **VoiceOver.** Icons, the banner image and the progress bar graphic are not accessibility elements. VoiceOver reads the text and the button. * **Reduce Motion.** The reveal and collapse animations and the progress bar fill are skipped when **Reduce Motion** is on. * **Right-to-left layouts.** Components lay out with leading and trailing edges, and the progress bar fills from the leading edge, so they mirror in right-to-left languages. * **No overlay filter.** iOS does not let other apps draw over yours, so the components have no equivalent of Android’s filter for taps that pass through another app’s overlay. The SDK sets no custom accessibility labels on components, and draws no dismiss control on them. For what your campaigns and app should do, see [Theming and accessibility](/mobile/concepts/theming-and-accessibility/). ## Limits [Section titled “Limits”](#limits) * The theme is fixed at `Grona.start`. There is no call to change it while the app runs. * Components are UIKit views. `GronaSlot` wraps them for SwiftUI, so SwiftUI environment values such as `.font`, `.tint` or `.foregroundStyle` do not reach them. ## Next steps [Section titled “Next steps”](#next-steps) * [App Store privacy and privacy manifest](/mobile/ios/privacy-manifest/). * [Test and debug](/mobile/ios/test-and-debug/) your integration.
# Track events
> Forward view, cart, checkout and purchase events from your analytics layer so Grona can measure campaigns.
This page sends commerce events to Grona. Grona logs its own campaign events, such as exposures and taps, but it needs your app to report what the shopper did next: products viewed, cart changes, checkout and purchase. Forward them from the same analytics layer that feeds your other tools. ## The events [Section titled “The events”](#the-events) Build each event with a `GronaEvent` factory and pass it to `Grona.track`. Money is in minor units, so `349` is 3.49. | Factory | Send it when | | ------------------------------------------------------ | ----------------------------------------------------------------------------- | | `.viewItem(productID:priceMinor:)` | A product page opens. | | `.addToCart(productID:quantity:priceMinor:)` | A quantity of a product is added to the cart. `priceMinor` is the unit price. | | `.removeFromCart(productID:quantity:)` | A quantity of a product is removed from the cart. | | `.beginCheckout(totalMinor:)` | The shopper starts checkout. | | `.purchase(orderID:totalMinor:productIDs:treatments:)` | Your order backend has confirmed an order. | `Grona.track` is safe from any thread. 1. Send `viewItem` from your product screen:
```swift
import Grona
Grona.track(.viewItem(productID: "sku_10432", priceMinor: 349))
```
2. Send `addToCart` and `removeFromCart` from your cart’s change notification, once per product whose quantity moved. That covers changes from Grona actions and from your own screens alike.
```swift
import Grona
func cartDidChange(from before: AcmeCart, to after: AcmeCart) {
let ids = Set(before.lines.map(\.productID)).union(after.lines.map(\.productID))
for id in ids {
let delta = after.quantity(of: id) - before.quantity(of: id)
if delta > 0 {
Grona.track(.addToCart(productID: id, quantity: delta, priceMinor: after.unitPriceMinor(of: id)))
} else if delta < 0 {
Grona.track(.removeFromCart(productID: id, quantity: -delta))
}
}
}
```
3. Send `beginCheckout` when checkout opens:
```swift
Grona.track(.beginCheckout(totalMinor: cart.totalMinor)) // 897 is 8.97
```
4. Send `purchase` after your order backend answers. Pass every product id in the order, and only the treatments the backend applied:
```swift
Grona.track(.purchase(
orderID: order.id,
totalMinor: order.totalMinor,
productIDs: order.lines.map(\.productID),
treatments: appliedTreatments // from Grona.activeTreatments(), filtered to what the backend applied
))
```
For how to collect the treatments at checkout, see [Offers and grants](/mobile/ios/offers-and-grants/#send-treatments-with-the-order). ## What leaves the device [Section titled “What leaves the device”](#what-leaves-the-device) The SDK minimises what it sends. You pass full data and it strips what Grona does not need. * **Session products.** A session product is one that a Grona campaign showed or added in this session. `viewItem`, `addToCart` and `removeFromCart` are always counted, but they carry the product id and price only for session products. For any other product those fields are left out. * **Purchases.** `purchase` keeps the order id, the total, the currency of your latest basket snapshot, the treatments and only the session products in the order. The full basket never leaves the device. * **Exception: keyed placements.** On keyed placements such as `cart_line`, campaign events carry the line’s product id as their `key`, in every arm. So while a `cart_line` campaign is live, the product ids of the cart lines bound to it leave the device. * **Never sent.** Search text, names, phone numbers, emails and browsing history. Pass only an opaque customer id to [`identify`](/mobile/ios/identify-shoppers/). Caution Client purchase events can be spoofed, so Grona uses them only to join exposure to orders. Report revenue from your order system, server to server. See [Report revenue](/mobile/backend/revenue/). ## Events Grona logs itself [Section titled “Events Grona logs itself”](#events-grona-logs-itself) Do not send these. The SDK logs them for every placement it serves. | Event | When | | ---------------- | ------------------------------------------------------------------------------ | | `eligible` | A decision is computed for a placement, once per visit, for every arm | | `anchor_visible` | The slot or anchor’s top edge has been on screen for one second, for every arm | | `served` | A treatment first reaches its slot or closure | | `exposure` | Half the content has been visible for one second (diagnostic) | | `action` | An add, add-all, replace or navigate from a tap or your call on a decision | | `dismiss` | A dismiss | For how these are analysed, see [Events and exposure](/mobile/concepts/events-and-exposure/). ## Delivery [Section titled “Delivery”](#delivery) Events are held or sent according to the shopper’s [consent](/mobile/ios/consent/). Once consent is granted: * The SDK sends events while the app is in the foreground every 30 seconds, once 20 are queued, and when the app moves to the background. It asks iOS for background time for each send, so a send that starts as the shopper leaves the app can finish. * A batch holds at most 50 events and is gzipped and sent over https. * The queue on disk holds at most 1,000 events or 7 days of events. The oldest go first. * A failed batch is retried at most 3 times with backoff. Without an `eventsURL` in your options, the SDK sends no events and keeps no event queue. Debug builds keep the last 200 events in memory for the [debug view](/mobile/ios/test-and-debug/). ## Next steps [Section titled “Next steps”](#next-steps) * [Set consent](/mobile/ios/consent/). * [Test and debug](/mobile/ios/test-and-debug/) your events.
# Website integration overview
> What the Grona script does on your pages and how the website docs are organised.
Grona runs on your website through one script snippet that you add to the top of every page’s ``. This page explains what that script does on each page view, so you can reason about flicker, targeting and measurement, and it maps the rest of the website docs. ## What the script does on a page [Section titled “What the script does on a page”](#what-the-script-does-on-a-page) The snippet has three lines: a preconnect hint to `cdn.grona.ai`, the anti-flicker script and the runtime script (`main.js`). Both scripts are built for your workspace and served from `cdn.grona.ai`. On every page view they do the following, in order. 1. **Hide the page.** The anti-flicker script runs synchronously and hides the page body, so visitors do not see the original content change in front of them. Search crawlers and testing tools are recognised by their user agent and skip this step, and everything after it. 2. **Load the runtime.** `main.js` downloads asynchronously. It sets up a random visitor id in first-party storage, or reads the one it set earlier. 3. **Check campaigns in priority order.** For each active campaign, starting with the highest priority, the runtime checks the schedule, the UTM rules, the URL rules and any audience rules. The first campaign that enrols the visitor wins the page view, and lower-priority campaigns do not run on it. 4. **Apply a variation.** The runtime picks the visitor’s variation. The choice is deterministic and sticky per visitor, so the same visitor sees the same variation on every page and visit on the same site address, unless you change the campaign’s traffic split. It downloads that variation’s changes from the CDN and applies them once the page is ready. 5. **Reveal the page.** The runtime shows the page as soon as the changes are applied, or straight away if no campaign matched. A safety timer reveals the page 5 seconds after the anti-flicker script ran if the runtime has not finished by then. 6. **Measure.** Once a campaign has enrolled the visitor, the runtime records the page view, scroll depth, time on page and clicks on elements it has tagged. Conversions you send with [`grona('track', ...)`](/web/help/javascript-api/) are recorded on any page. Everything goes in batches to `api.grona.ai`. While measuring every page is switched on for your workspace, the runtime also counts one page view on pages no campaign takes; see [Data, privacy and security](/get-started/data-privacy-security/). On single-page applications the runtime also watches for route changes and runs the campaign check again for the new address. See [Single-page applications](/web/configure/single-page-apps/) for what is and is not supported. ## Install [Section titled “Install”](#install) [Install the Grona script](/web/install/add-the-script/)The snippet and where it goes. [Verify your installation](/web/install/verify/)Confirm the script is on your pages. [Implementation checklist](/web/install/checklist/)Everything to check before you go live. [Remove Grona](/web/install/uninstall/)Take the script and its storage off your site. ## Configure [Section titled “Configure”](#configure) [Prevent flicker](/web/configure/prevent-flicker/)How the anti-flicker script works and its trade-offs. [Single-page applications](/web/configure/single-page-apps/)Route changes, re-renders and their limits. [Server rendering and hydration](/web/configure/ssr-and-hydration/)Avoid conflicts with frameworks that hydrate. [Content Security Policy](/web/configure/content-security-policy/)The directives the script needs. [Consent and storage](/web/configure/consent-and-cookies/)Consent modes and every storage key Grona uses. [Consent platforms](/web/configure/consent-platforms/)Which consent tools Grona reads. [Page variables](/web/configure/segments-and-page-variables/)The fields and rules for page-variable targeting. [Visual editor requirements](/web/configure/visual-editor-requirements/)What a page needs for the editor to open it. [Domains and subdomains](/web/configure/domains-and-subdomains/)How visitor ids behave across hosts. [Performance](/web/configure/performance/)Requests, caching and page-speed effects. [Supported browsers](/web/configure/browser-support/)Which visitor browsers the script runs in. ## Platform guides [Section titled “Platform guides”](#platform-guides) [Shopify](/web/platforms/shopify/) [WordPress](/web/platforms/wordpress/) [WooCommerce](/web/platforms/woocommerce/) [Webflow](/web/platforms/webflow/) [Wix](/web/platforms/wix/) [Squarespace](/web/platforms/squarespace/) [BigCommerce](/web/platforms/bigcommerce/) [Elementor](/web/platforms/elementor/) [HubSpot](/web/platforms/hubspot/) [Google Tag Manager](/web/platforms/google-tag-manager/) [Custom sites and frameworks](/web/platforms/custom-sites/) ## How-to guides [Section titled “How-to guides”](#how-to-guides) [Preview and QA a variation](/web/guides/preview-and-qa/)Check a variation on your live site before launch. [Target pages with URL rules](/web/guides/target-pages/)One page, a group of pages or the whole site. [Target audiences](/web/guides/target-audiences/)Location, device, language, time and earlier visits. [Target visitors by page variable](/web/guides/target-by-segment/)Target a page variable from an API campaign. [Track conversions and custom events](/web/guides/track-conversions/)Send goals with the JavaScript API or a goal snippet. ## Help [Section titled “Help”](#help) [JavaScript API](/web/help/javascript-api/)The public grona() command. [Debug in the browser](/web/help/debug-in-the-browser/)See what Grona did on a page. [Troubleshooting](/web/help/troubleshooting/)Fixes organised by symptom. [FAQ](/web/help/faq/)SEO, ad blockers, Core Web Vitals and more.
# Supported browsers
> Which browsers run Grona changes and what happens on the rest.
Grona’s runtime runs in your visitors’ browsers as modern JavaScript, without transpilation. It works in current evergreen browsers, such as Chrome, Edge, Firefox and Safari, that support the features below. This page lists those features and what happens in a browser that lacks them. ## What the runtime needs [Section titled “What the runtime needs”](#what-the-runtime-needs) The runtime uses ES2017 syntax and these browser APIs. The syntax in the first row is a hard requirement; the optional APIs are listed under graceful degradation below. | Feature | Used for | | ------------------------------------------------------------------------ | ---------------------------------------------------------------------- | | `async` and `await`, `const` and `let`, arrow functions, spread in calls | The runtime’s own syntax. Without them the script does not run at all. | | `fetch`, including `keepalive` | Downloading variation changes and sending events | | `URL` and `URLSearchParams` | Matching pages and reading the address | | `TextEncoder` | Assigning visitors to variations | | `Object.assign` | Building tracking events | | `MutationObserver` | Applying changes to elements that render late | | `navigator.sendBeacon` | Sending events when the page is hidden or closed | | `crypto.randomUUID` | Creating the visitor id (with a fallback when it is missing) | Grona does not publish tested minimum browser versions. ## Graceful degradation [Section titled “Graceful degradation”](#graceful-degradation) Some features are optional. Without them, the runtime still applies changes and skips only the part that needs them: * Without `MutationObserver`, changes to elements that are not in the page yet are not retried. * Without `navigator.sendBeacon`, the events normally sent as the page closes are skipped. * Without the Resource Timing API, location is unknown, so location rules do not match. * Without usable storage, Grona keeps the visitor id in memory for that page only, so the visitor can get a new id and a different variation on every page load. ## Internet Explorer 11 [Section titled “Internet Explorer 11”](#internet-explorer-11) Internet Explorer 11 is not supported. Its JavaScript engine cannot parse `async` functions, so `main.js` fails as a whole and no change applies. If the anti-flicker script runs, the page stays hidden until its 5-second timeout, then shows your original content.
# Consent and storage
> Grona's consent modes, what runs before consent, and every key the script stores.
Before the Grona script goes live, your privacy and legal reviewers will want to know what it stores and when. This page covers the two consent modes, what runs before a visitor consents in each, and every storage key the script writes. ## Consent modes [Section titled “Consent modes”](#consent-modes) Each workspace has a consent mode. Set it in the Grona dashboard under **Settings > Privacy and consent**, where the question is “Must visitors consent before Grona stores anything?”. “Yes, wait for consent” is **Wait for consent** below; “No, consent isn’t needed” is **Not required**. | Mode | Before consent | When it applies | | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | | **Not required** (also how a workspace that has not answered behaves) | Everything runs from the first page view: the visitor id is stored, variations apply and events are sent. | When you answer “No, consent isn’t needed”, and while the question shows “Not answered yet”. | | **Wait for consent** | Nothing is stored and nothing is sent, apart from the `timeout` report below. The visitor sees your original page. | Only when you answer “Yes, wait for consent”. | By default, Grona does not wait for consent. If you need consent before analytics storage, select **Wait for consent**. ### How Wait for consent behaves [Section titled “How Wait for consent behaves”](#how-wait-for-consent-behaves) * Grona reads the visitor’s answer from your consent tool. “Not answered yet” counts as no. See [Consent platforms](/web/configure/consent-platforms/) for the tools it reads. * Consent is read once per page view. A visitor who accepts while on a page is counted from their next page view; the page where they accepted shows your original content and is not counted. * Without consent, visitors see your original page and are left out of tests. The one exception is an audience rule group that shows one version to everyone it matches, with no split, and needs no storage (for example a rule on device, location, time of day or language). Visitors who match such a group see its version, and nothing is stored or sent for them either. * Goals from Shopify’s custom pixel are always dropped in this mode, because the pixel’s sandbox cannot see the visitor’s consent. ### What runs regardless of consent [Section titled “What runs regardless of consent”](#what-runs-regardless-of-consent) * **Preview links** apply their variation without consent, and record no page views, clicks or conversions in either mode. They follow the consent mode for storage: under **Wait for consent** nothing is stored before consent, and under **Not required** the visitor and visit ids are created as on any page. * **The anti-flicker `timeout` report.** `antiflicker.js` sends a `timeout` report to `api.grona.ai/track/antiflicker` if its 5-second timer has to reveal the page, whatever your consent setting is. It carries your workspace id, the page URL, the user agent and a duration, and it includes the visitor and visit ids only when `main.js` has already set them, which never happens before consent under **Wait for consent**. `main.js` also sends a `loaded` timing report when it starts on each page view, with no visitor or visit id. Under **Wait for consent** it is sent only when the visitor has consented. [Prevent flicker](/web/configure/prevent-flicker/#timing-reports) describes both reports. ### No JavaScript consent API [Section titled “No JavaScript consent API”](#no-javascript-consent-api) There is no JavaScript call or flag to pass consent to Grona. The only public JavaScript command is `grona('track', …)`. If your consent tool is not one Grona reads, have it send a Google Consent Mode update; see [Consent platforms](/web/configure/consent-platforms/). ## Cookies [Section titled “Cookies”](#cookies) `main.js` writes no cookies. It reads two kinds: the consent cookies of Cookiebot and OneTrust (in **Wait for consent** mode), and Shopify’s `localization` cookie for market targeting. ## Storage [Section titled “Storage”](#storage) Everything Grona stores is first-party Web Storage on your own domain. Grona sets no third-party storage. Which keys appear depends on the features your campaigns use. | Key | Storage | Purpose | Lifetime | When present | | ------------------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------ | | `grona_client_data` | `localStorage` | `{clientId, createdAt}`: a random visitor id, used to assign variations and to connect page views and goals | 1 year, then a new id | Always, unless consent is required and not given | | `grona_session_id` | `sessionStorage` | The visit id | The tab’s life; renewed after 30 minutes of inactivity or 7 hours in total | Always, unless consent is required and not given | | `grona_session_start` | `sessionStorage` | When the visit started | As `grona_session_id` | Always, unless consent is required and not given | | `grona_last_activity` | `sessionStorage` | Time of the last mouse, key, scroll, touch or click | As `grona_session_id` | Always, unless consent is required and not given | | `grona_recent_pageviews` | `sessionStorage` | Page-view ids from the last 90 seconds, so a quick reload is not counted twice | Entries expire after 90 seconds | Always, unless consent is required and not given | | `grona_consent` | `localStorage` | `{granted: true, at}`: Grona’s note that the visitor consented to analytics. It never stores a “no”. | 1 year, or until a consent tool says no | **Wait for consent** workspaces only | | `grona_visit_note` | `localStorage` | Up to 4 KB: counts of earlier visits, kinds of pages seen, goals reached, and ad-link values an audience rule names. Never an address or form input. | Until the campaign ends, or a set number of days after the first visit (30 by default) | Audience rules that use visit history | | `grona_this_visit` | `sessionStorage` | Counters for this visit, such as named page lists and cart adds | The visit | Audience rules that use this visit’s activity | | `grona_orders` | `localStorage` | The last 100 order or booking codes, to avoid counting one twice | Until replaced | Workspaces using Grona’s built-in booking or store listeners | | `grona_cart_changed` | `sessionStorage` | A note that a form added to the cart, so the next page reads the cart once | Until read | Campaigns that use cart contents | | `grona_cart_reached` | `sessionStorage` | Cart thresholds already passed in this visit | The visit | Campaigns that measure cart thresholds | Under **Wait for consent**, no key except `grona_consent` is written until the visitor consents, including the feature keys in the last five rows. Visitors can also see these on your pages, although they are not storage: * the `?v=grona-` query parameter, added to the address after a variation applies; * `data-grona-*` and `data-gro-original_*` attributes on changed elements. Storage is per origin, so a visitor has separate keys on `www.shop.example.com` and `shop.example.com`. See [Domains and subdomains](/web/configure/domains-and-subdomains/). When storage is blocked, Grona keeps its values in memory for that page only.
# Consent platforms
> How Grona reads Google Consent Mode, OneTrust, Cookiebot and Shopify's customer privacy API.
When your workspace uses **Wait for consent**, Grona needs your consent tool’s answer before it stores or sends anything. It reads that answer from four consent tools, and from what they stored on earlier pages, with no setup on your side. This page lists what it reads from each, the events it listens for, and what to do if your tool is not one of them. The modes themselves are described on [Consent and storage](/web/configure/consent-and-cookies/). ## Sources Grona reads [Section titled “Sources Grona reads”](#sources-grona-reads) Grona asks each source in this order, and the first one with an answer decides: | Order | Source | What counts as consent | | ----- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | Google Consent Mode | The newest `consent` `update` in `window.dataLayer` with `analytics_storage: 'granted'`. A `default` entry never counts, even when it is `granted`. | | 2 | OneTrust | Group `C0002` (performance cookies) is in `window.OnetrustActiveGroups`. | | 3 | Cookiebot | `Cookiebot.consent.statistics` is `true`. Before the visitor answers, Cookiebot gives no answer. | | 4 | Shopify customer privacy API | `Shopify.customerPrivacy.analyticsProcessingAllowed()` returns `true`. Only a recorded “no” counts as a refusal; “not asked yet” gives no answer. | | 5 | An earlier page of the visit | An answer one of the tools announced on a previous page. | | 6 | Stored consent cookies | For returning visitors: Cookiebot’s `CookieConsent` cookie (statistics) and OneTrust’s `OptanonConsent` cookie (group `C0002`). | | 7 | Grona’s own note | The `grona_consent` key Grona writes after a yes, kept for a year. | If no source has an answer, Grona treats the visitor as not consenting. The stored sources matter because Grona runs at the top of ``, usually before your consent tool’s own script. On a returning visitor’s first page view, the cookies and Grona’s note supply the answer before the tool has loaded. Google Consent Mode and Shopify keep nothing Grona can read on the next page, which is why Grona keeps its own note of a yes. It removes the note as soon as any tool says no. ## Events Grona listens for [Section titled “Events Grona listens for”](#events-grona-listens-for) Answers given while a page is open are picked up from these events and count from the visitor’s next page view: | Source | Event | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | Google Consent Mode | Grona watches `dataLayer.push` for `consent` `update` entries. It does this even when it runs before your site creates `window.dataLayer`. | | OneTrust | `OneTrustGroupsUpdated` on `window` | | Cookiebot | `CookiebotOnAccept` and `CookiebotOnDecline` on `window` | | Shopify | `visitorConsentCollected` on `document` | Grona also re-reads the tools when the visitor leaves the page or hides the tab, to catch an answer that arrived another way. ## Other consent tools [Section titled “Other consent tools”](#other-consent-tools) Grona reads no other consent tool. With **Wait for consent**, visitors on a site that uses another tool are always treated as not consenting, because Grona never finds an answer. They see your original page and nothing is counted. To use another tool, have it send a Google Consent Mode update when the visitor answers. If your tool cannot do that itself, push the update yourself from its consent callback:
```js
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
// Call this from your consent tool's callback once the visitor has answered.
function sendAnalyticsConsent(granted) {
gtag('consent', 'update', { analytics_storage: granted ? 'granted' : 'denied' });
}
```
Caution If you use Google Consent Mode with `analytics_storage` set to `granted` in a `default` command and never send an `update`, Grona finds no answer and treats every visitor as not consenting. Send an `update` once the visitor has answered. ## Next steps [Section titled “Next steps”](#next-steps) * [Consent and storage](/web/configure/consent-and-cookies/) * [Implementation checklist](/web/install/checklist/)
# Content Security Policy
> The exact CSP directives Grona needs, with a policy you can copy.
If your site sends a `Content-Security-Policy` header or meta tag, it must allow Grona’s two domains, or the browser blocks parts of the runtime. This page lists every directive Grona needs, gives a complete policy to start from, and explains the cases that need more. ## Directives [Section titled “Directives”](#directives) | Directive | Source | Why | | ------------- | ---------------------- | --------------------------------------------------------------------------------------------- | | `script-src` | `https://cdn.grona.ai` | Loads `antiflicker.js` and `main.js`. | | `connect-src` | `https://cdn.grona.ai` | `main.js` downloads each variation’s changes file from the CDN. | | `connect-src` | `https://api.grona.ai` | Tracking events and preview data go to the API. | | `img-src` | `https://cdn.grona.ai` | Images and assets used in variations are served from the CDN. | | `style-src` | `'unsafe-inline'` | The anti-flicker `