Create a campaign
package main
import ( "fmt" "strings" "net/http" "io")
func main() {
url := "https://api.grona.ai/v1/campaigns"
payload := strings.NewReader("{ \"name\": \"Free delivery bar on the home page\", \"url\": \"https://shop.example.com/\", \"externalId\": \"CMP-1024\" }")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>") req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close() body, _ := io.ReadAll(res.Body)
fmt.Println(res) fmt.Println(string(body))
}OkHttpClient client = new OkHttpClient();
MediaType mediaType = MediaType.parse("application/json");RequestBody body = RequestBody.create(mediaType, "{ \"name\": \"Free delivery bar on the home page\", \"url\": \"https://shop.example.com/\", \"externalId\": \"CMP-1024\" }");Request request = new Request.Builder() .url("https://api.grona.ai/v1/campaigns") .post(body) .addHeader("Authorization", "Bearer <token>") .addHeader("Content-Type", "application/json") .build();
Response response = client.newCall(request).execute();const url = 'https://api.grona.ai/v1/campaigns';const options = { method: 'POST', headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'}, body: '{"name":"Free delivery bar on the home page","url":"https://shop.example.com/","externalId":"CMP-1024"}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}curl --request POST \ --url https://api.grona.ai/v1/campaigns \ --header 'Authorization: Bearer <token>' \ --header 'Content-Type: application/json' \ --data '{ "name": "Free delivery bar on the home page", "url": "https://shop.example.com/", "externalId": "CMP-1024" }'Creates a campaign and its variations in one call. A general campaign gets one
variation. An abTest campaign gets two, Control and Test, in that order.
- New campaigns start
paused. Nothing reaches visitors until you set the campaignlive. - The response is the only place that returns an editor link for every variation at once.
Each link expires 15 minutes after
generatedAt. - The snippet check on the campaign’s website runs in the background, so
script.statusstarts aschecking. - Send
externalIdto make retries safe: a repeated create returns409 conflictinstead of a second campaign.
trafficSplit is keyed by Control and Test here, because the variation ids do not
exist until this call returns. Everywhere else it is keyed by variation id.
Authorizations
Section titled “Authorizations”Request Bodyrequired
Section titled “Request Bodyrequired”segmentId and segmentValue go together: send both or neither. With neither, every
visitor on the targeted pages is eligible. trafficSplit is for abTest campaigns only.
slug, condition and list are for multiple campaigns only. Sending any of these
with the wrong kind of campaign returns 400.
object
Campaign name.
The page the campaign runs on. With pageTargeting multiple, a sample page
of the kind you want to cover; the editor opens this page. It cannot be changed later.
Leave out any #fragment: preview reads gronaPreview from the query string only.
Your own id for a campaign: 1 to 128 letters, digits, _, ., : or -. Unique per
workspace. A UUID, an ObjectId or a plain number are all valid.
The name of a variable your page sets on window: 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.
The value the page variable must hold for a visitor to be eligible: 1 to 128
letters, digits, _, ., : or -. Compared as an exact string match.
general creates one variation. abTest creates two, Control and Test.
specific runs on the one page in url. multiple runs on every page matched by
slug, condition and list.
abTest only. Optional; the default is 50 and 50. Sending it for a general campaign returns 400.
object
Required with multiple. One segment of the sample url’s path, spelled
exactly as it appears there. On each page, Grona takes the path segment at the same
position and compares it with each entry of list. The comparison is exact and
case-sensitive. A page with no segment at that position never matches, whichever
condition you use.
Only for multiple campaigns. Grona takes the path segment of each page at the same
position as slug in the sample url. included matches pages whose segment there is
in list. not_included matches pages whose segment there is not in list, which can
be far more pages than you intend, so preview the variation on some of them before you
set the campaign live. A page with no segment at that position never matches.
Required with multiple. The path segments to compare against, 1 to 50 of them.
Examples
General campaign on one page
{ "name": "Free delivery bar on the home page", "url": "https://shop.example.com/", "externalId": "CMP-1024"}General campaign for one page variable value
{ "name": "Free delivery bar for returning buyers", "url": "https://shop.example.com/", "segmentId": "audience_id", "segmentValue": "returning_buyer"}A/B test across dairy and bakery pages (multi-page targeting)
{ "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 }}Responses
Section titled “Responses”Created. The campaign, with an editor link for each variation.
object
object
Grona’s campaign id.
live runs the campaign. paused stops it. New campaigns start paused.
The website the campaign runs on, resolved from its url.
Whether the Grona snippet was found on the campaign’s website.
object
checking: a check is running. present: the snippet was found. absent: the
snippet was not found, or Grona could not load the page. unknown: no check is
running and none has completed. New campaigns read checking, not unknown.
ISO 8601 in UTC, with millisecond precision.
ISO 8601 in UTC, with millisecond precision.
general creates one variation. abTest creates two, Control and Test.
specific runs on the one page in url. multiple runs on every page matched by
slug, condition and list.
Only for multiple campaigns. The sample page, stored without its query string or fragment.
Only for multiple campaigns. The sample page’s path segment whose position is compared.
Only for multiple campaigns. Grona takes the path segment of each page at the same
position as slug in the sample url. included matches pages whose segment there is
in list. not_included matches pages whose segment there is not in list, which can
be far more pages than you intend, so preview the variation on some of them before you
set the campaign live. A page with no segment at that position never matches.
Only for multiple campaigns. The segments compared against.
Keyed by variation id. On update, every variation must appear and the values must total 100.
object
One entry for general, two for abTest in the order Control, Test. Each has an editor link.
object
The variation id. Use it for editor links and in trafficSplit on update.
Set by Grona. null when the campaign refers to a variation that no longer exists.
control when the variation’s name contains the word “control” in any letter case,
variant otherwise. An A/B test reads control, variant. A variation whose name is
null reads variant.
The campaign’s page with the variation’s saved changes applied, through the
gronaPreview parameter. It does not expire. null only when the variation no
longer exists.
Editor link for this variation. Its token expires 15 minutes after generatedAt.
ISO 8601 in UTC, with millisecond precision.
ISO 8601 in UTC, with millisecond precision.
object
The workspace resolved from the secret key.
Examples
General campaign on one page
{ "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" }}A/B test across dairy and bakery pages (multi-page targeting)
{ "data": { "id": "671a3d52e4b0a1f2c3d40021", "externalId": null, "name": "Breakfast banner on dairy and bakery pages", "status": "paused", "domain": "https://shop.example.com", "script": { "status": "checking", "checkedAt": null }, "createdAt": "2026-10-20T10:02:44.105Z", "updatedAt": "2026-10-20T10:02:44.391Z", "type": "abTest", "pageTargeting": "multiple", "segmentId": null, "segmentValue": null, "url": "https://shop.example.com/dairy/whole-milk", "slug": "dairy", "condition": "included", "list": [ "dairy", "bakery" ], "trafficSplit": { "671a3d52e4b0a1f2c3d40022": 60, "671a3d52e4b0a1f2c3d40023": 40 }, "variations": [ { "id": "671a3d52e4b0a1f2c3d40022", "name": "Control", "type": "control", "previewUrl": "https://shop.example.com/dairy/whole-milk?gronaPreview=671a3d52e4b0a1f2c3d40022", "editUrl": "https://api.grona.ai/v1/editor/launch?token=eyJhbGciOi...", "generatedAt": "2026-10-20T10:02:44.420Z", "expiresAt": "2026-10-20T10:17:44.420Z" }, { "id": "671a3d52e4b0a1f2c3d40023", "name": "Test", "type": "variant", "previewUrl": "https://shop.example.com/dairy/whole-milk?gronaPreview=671a3d52e4b0a1f2c3d40023", "editUrl": "https://api.grona.ai/v1/editor/launch?token=eyJhbGciOi...", "generatedAt": "2026-10-20T10:02:44.426Z", "expiresAt": "2026-10-20T10:17:44.426Z" } ] }, "meta": { "workspaceId": "65e8a1c47d2f9b03e6a15c42" }}The request is invalid, for example a missing name or url, or a field that does not
apply to this kind of campaign. error.code is invalid_request and error.message
says what to change. Nothing was created.
object
object
Stable and machine-readable. Branch on this, and on the HTTP status for codes you do not know.
Human-readable. The wording can change, so do not parse it.
Example
{ "error": { "code": "invalid_request", "message": "`name` is required." }}The secret key is missing, malformed, unknown or revoked. error.code is unauthorized.
object
object
Stable and machine-readable. Branch on this, and on the HTTP status for codes you do not know.
Human-readable. The wording can change, so do not parse it.
Example
{ "error": { "code": "unauthorized", "message": "Missing or invalid API key." }}The externalId is already used by another campaign in the workspace. error.code is
conflict.
object
object
Stable and machine-readable. Branch on this, and on the HTTP status for codes you do not know.
Human-readable. The wording can change, so do not parse it.
Example
{ "error": { "code": "conflict", "message": "externalId `CMP-1024` is already used by another campaign." }}Too many requests. Retry with exponential backoff and jitter, and wait at least as long
as Retry-After when it is present. Branch on the status code; the body is not
guaranteed to use the error envelope.
Headers
Section titled “Headers”How long to wait before retrying, in seconds or as an HTTP date.
Unexpected server error. error.code is internal_error. Retry with backoff; if it
persists, contact support@grona.ai with the time of the request and the endpoint.
object
object
Stable and machine-readable. Branch on this, and on the HTTP status for codes you do not know.
Human-readable. The wording can change, so do not parse it.
Example
{ "error": { "code": "internal_error", "message": "Something went wrong on our side." }}Temporarily unavailable. Retry with exponential backoff and jitter, and wait at least as
long as Retry-After when it is present. Branch on the status code; the body is not
guaranteed to use the error envelope.
Headers
Section titled “Headers”How long to wait before retrying, in seconds or as an HTTP date.