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.
Requests
Section titled “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.
Identifiers
Section titled “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”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 byexternalIdalways finds exactly one campaign. - Sending a campaign’s own current
externalIdagain is fine. - Sending
nullon update removes it and frees it for another campaign. Deleting a campaign frees itsexternalIdtoo. - It comes back on every campaign in every response.
curl https://api.grona.ai/v1/campaigns/CMP-1024 \ -H "Authorization: Bearer $GRONA_SECRET_KEY"Timestamps
Section titled “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”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”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.
Traffic split keys
Section titled “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”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”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”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.
Safe retries and idempotency
Section titled “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.
- Send the create with an
externalId. - If the response is lost or times out, send the same create again.
- If the retry returns
409 conflict, the first attempt succeeded. Fetch the campaign withGET /v1/campaigns/{externalId}. - 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;}Retry updates and deletes by Grona’s id
Section titled “Retry updates and deletes by Grona’s 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.