Skip to content
The Public API is available on request. Email support@grona.ai to get access.

Conventions

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.

  • 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.

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.

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.
Terminal window
curl https://api.grona.ai/v1/campaigns/CMP-1024 \
-H "Authorization: Bearer $GRONA_SECRET_KEY"

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.

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.

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.

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.

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.

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.

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.

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.
// 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;
}

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.