This is the full 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)))); } } ``` Note Only wrap requests that are safe to retry, as listed above. Send creates through it only when they carry an `externalId`, and updates and deletes only when the path uses Grona’s campaign `id`. ## 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. | Note The package `ai.grona.sdk.debug` holds the internal API that `grona-debug` uses. It requires an opt-in that fails the build by default, is not part of the public API, and may change in any release. # 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() ``` Note Grona checks quantities and stock, but your cart still owns its own rules, such as per-customer limits. If your cart refuses a line, leave it out and return `PARTIAL` or `REJECTED`. ## 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