Create campaigns from your platform
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”- You need a workspace secret key in the
GRONA_SECRET_KEYenvironment variable of the server that calls Grona. See Authentication. - The Grona snippet must be installed on your website. See Install the Grona script.
- The Node samples need Node 18 or later and use this small helper. Save it next to the other files as
grona.mjs:
// 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”-
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.
Terminal window 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/"}'check.mjs 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}`);}presentmeans the snippet was found.absentmeans it was not found or the page could not be loaded. The check covers only the page you send. If you getabsenton a page where the snippet is installed, see Verify installations. -
Create the campaign.
Send a name and the page.
externalIdstores the id of the record in your own product and makes retries safe. This campaign shows to every visitor on the page; 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.Terminal window 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"}'create.mjs 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:{"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.idand everyvariations[].id. You need the variation ids for editor links and, in an A/B test, for the traffic split. script.statusischeckingbecause 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.
- The campaign starts
-
Open the editor links.
Each variation has an
editUrlthat 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:
Terminal window curl https://api.grona.ai/v1/variations/edit-link \-H "Authorization: Bearer $GRONA_SECRET_KEY" \-H "Content-Type: application/json" \-d '{"variationId": "671a3c90e4b0a1f2c3d40012"}'edit-link.mjs 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. -
Preview the variation.
Open the variation’s
previewUrlto see the page with the saved changes applied. It is the campaign’s page withgronaPreviewadded, using&instead of?when the URL already has a query string:https://shop.example.com/?gronaPreview=671a3c90e4b0a1f2c3d40012https://shop.example.com/search?q=milk&gronaPreview=671a3c90e4b0a1f2c3d40012Preview 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.
Grona reads
gronaPreviewfrom the query string only, so give a campaign aurlwithout a#fragment. -
Set the campaign live.
When the variation is ready, set the status to
live:Terminal window 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"}'go-live.mjs 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. -
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.The Public API has no endpoint for results. Read them in the Grona dashboard.
Campaign options
Section titled “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”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 has both shapes.
Run on several pages
Section titled “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:
{ "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/sourdoughmatches:bakeryis in the list.https://shop.example.com/frozen/peasdoes not match:frozenis not in the list.https://shop.example.com/Dairy/whole-milkdoes not match:Dairyis notdairy.https://shop.example.com/never matches: it has no first segment.
Sending slug, condition or list without multiple returns 400.
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”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:
<script> window.audience_id = 'returning_buyer';</script>The create request names the variable and the value:
{ "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 asname,location,status,top,parentandhistory, 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 covers how to set the variable on your site, and Page variables is the reference for the fields.
After launch
Section titled “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. - Read campaigns with
GET /v1/campaignsorGET /v1/campaigns/{campaignId}, using Grona’s id or yourexternalId. - Delete a campaign with
DELETE /v1/campaigns/{campaignId}. Deletion is permanent and frees theexternalId. Like a status change, it reaches visitors once your site script refreshes. If you retry an update or a delete, use Grona’sid, not yourexternalId; see Conventions.
Next steps
Section titled “Next steps”- Visual editor links: keep editor links fresh and handle them safely.
- Verify installations: what the snippet check covers and why it can read
absent. - Errors: every error code and which requests are safe to retry.