Public API: Experiments

The experiments API manages the full experiment lifecycle in a project: create an experiment, read it back, change its configuration, and drive its status. Status changes (launch, pause, stop, archive, schedule) each have their own endpoint below.

All experiment endpoints live under a project and authenticate with a Bearer token: a service token or a personal access token. The org comes from the token, so the path carries only {projectId} (and {experimentId} for a single experiment), never {orgId}.

Plain text
https://app.avsb.cloud/api/v1/projects/{projectId}/experiments
Plain text1 line

Identifiers may be the canonical cuid or the numeric short id; the API normalises either to the stable cuid it returns.

Scopes

A scope is a named permission on your token. It decides exactly what that token is allowed to read or change.

OperationScope
List / get experimentsexperiments:read
Create / update / deleteexperiments:write
Lifecycle (launch, pause, stop, archive, unarchive, schedule, cancel-schedule)experiments:write

Every /api/v1 token is also rate-limited: a scoped token gets 600 reads and 120 writes per minute, and an admin:* token gets 600 requests per minute, reads and writes together. See Conventions for the response headers that show your remaining budget.

List experiments

GET /api/v1/projects/{projectId}/experiments: every experiment in the project, newest-updated first. Cursor-paginated (?limit=, up to 100, default 20; ?cursor=, the opaque value from page.nextCursor). Requires experiments:read.

curl "https://app.avsb.cloud/api/v1/projects/<projectId>/experiments?limit=20" \  -H "Authorization: Bearer avsb_svc_..."
Shell2 lines

Every experiment carries the same fields, whether it comes back from this list, from Get an experiment, or from a write below:

Response
{  "data": [    {      "id": "<experimentId>",      "shortId": 300001,      "projectId": "<projectId>",      "name": "Homepage hero",      "description": null,      "status": "RUNNING",      "experimentType": "VISUAL_CODE",      "editorMode": "CUSTOM_CODE",      "trafficAlloc": 1,      "statsEngine": "BAYESIAN",      "varianceReduction": "AUTO",      "customAlpha": null,      "isAATest": false,      "targetUrl": null,      "splitUrlControlUrl": null,      "splitUrlMatchMode": null,      "targetingRules": [],      "schedulingEnabled": false,      "scheduledStartAt": null,      "scheduledEndAt": null,      "timezone": null,      "hasPendingChanges": false,      "forwardToIntegrations": true,      "createdAt": "2026-06-18T00:00:00.000Z",      "updatedAt": "2026-06-18T01:00:00.000Z",      "launchedAt": "2026-06-18T01:00:00.000Z",      "completedAt": null,      "archivedAt": null,      "variations": [        { "id": "<variationId>", "shortId": 1, "name": "Control", "type": "CONTROL", "weight": 0.5, "displayName": null, "destinationUrl": null },        { "id": "<variationId2>", "shortId": 2, "name": "Variant A", "type": "VARIANT", "weight": 0.5, "displayName": null, "destinationUrl": null }      ],      "metrics": []    }  ],  "page": { "nextCursor": null, "hasMore": false }}
JSON40 lines

An out-of-range limit, or a cursor that is not a value this API produced, is refused rather than silently clamped:

400: limit out of range
{  "error": {    "code": "pagination_invalid",    "message": "limit must be an integer between 1 and 100",    "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#cursor-pagination",    "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77"  }}
JSON8 lines

Get an experiment

GET /api/v1/projects/{projectId}/experiments/{experimentId}: one experiment, in the same shape shown under List experiments, wrapped in the flat { data: <experiment> } envelope instead of an array. Requires experiments:read.

curl https://app.avsb.cloud/api/v1/projects/<projectId>/experiments/<experimentId> \  -H "Authorization: Bearer avsb_svc_..."
Shell2 lines

A cross-org id (an experiment that is not in the token's org and project) returns 404, worded the same as any other missing experiment, so a caller cannot tell "wrong org" from "does not exist":

404: experiment not found
{  "error": {    "code": "not_found",    "message": "Experiment not found",    "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#not-found-errors",    "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77"  }}
JSON8 lines

This is the same error every other endpoint on this page returns for an id it cannot find, so the sections below do not repeat it.

Create an experiment

POST /api/v1/projects/{projectId}/experiments: name is the only required field. Requires experiments:write. A field the API does not know (including status, which only the lifecycle endpoints change) is refused with 400 and an unrecognized_keys issue, exactly as on update.

The smallest request that works:

curl -X POST https://app.avsb.cloud/api/v1/projects/<projectId>/experiments \  -H "Authorization: Bearer avsb_svc_..." \  -H "Content-Type: application/json" \  -d '{ "name": "Homepage hero" }'
Shell4 lines

Everything else takes a default: experimentType defaults to VISUAL_CODE, editorMode to CUSTOM_CODE, trafficAlloc to 1, and when you leave out variations entirely, the API creates a 50/50 Control and Variant A pair for you.

A fuller request, naming the fields you are most likely to set yourself:

curl -X POST https://app.avsb.cloud/api/v1/projects/<projectId>/experiments \  -H "Authorization: Bearer avsb_svc_..." \  -H "Content-Type: application/json" \  -d '{    "name": "Homepage hero",    "description": "Testing a new hero layout on the homepage",    "trafficAlloc": 0.5,    "variations": [      { "name": "Control", "type": "CONTROL", "weight": 0.5 },      { "name": "Variant A", "type": "VARIANT", "weight": 0.5 }    ],    "audienceIds": ["<audienceId>"],    "metricIds": ["<metricBindingId>"],    "primaryMetricId": "<metricBindingId>"  }'
Shell15 lines
  • description is free text for your own team; leave it out and it is stored as an empty string.
  • trafficAlloc is the share of matching visitors who enter the experiment at all, from 0 to 1.
  • variations lists the experiment's variations: each one is a specific version being tested, the control or one of the challengers. Send between 2 and 4 of them, exactly one with "type": "CONTROL", each with its own name (names are compared ignoring case and surrounding spaces). Their weight values must sum to 1.0 (a small rounding tolerance is allowed). The same rule applies when you send variations on an update, and an A/A test (isAATest: true) takes exactly 2.
  • audienceIds and metricIds attach existing audiences and metrics by id. metricIds takes metric binding ids (the same ids the metric bindings API returns), not raw metric ids. primaryMetricId must be one of the ids in metricIds, and marks which metric decides the result.

Responds 201 with the created experiment, in the same shape shown under List experiments, now with "status": "DRAFT".

Info

New experiments start in DRAFT. Use the lifecycle endpoints below to launch them; status is never set through create or update.

Warning

Set editorMode to VISUAL and targetUrl becomes required. Leave editorMode at its CUSTOM_CODE default and targetUrl stays optional.

A variation list that breaks the rule is refused before anything is created. Five variations, a list with no control or with two, or a lone control each answer 400, with the reason on details.issues:

400: five variations
{  "error": {    "code": "validation_failed",    "message": "Request body failed validation",    "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#validation-errors",    "details": {      "issues": [        {          "param": "variations",          "path": ["variations"],          "code": "too_big",          "message": "Array must contain at most 4 element(s)",          "limit": 4,          "expected": "array"        }      ]    },    "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77"  }}
JSON20 lines

Variation weights that do not add up to 1.0 are refused the same way, with the sum you sent:

400: weights do not sum to 1
{  "error": {    "code": "validation_failed",    "message": "Variation weights must sum to 1.0",    "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#validation-errors",    "details": {      "reason": "variation_weights_invalid",      "field": "variations",      "issues": [        {          "path": ["variations"],          "message": "Variation weights must sum to 1.0",          "reason": "variation_weights_invalid"        }      ],      "sum": 0.6,      "expected": 1    },    "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77"  }}
JSON21 lines

Update an experiment

PATCH /api/v1/projects/{projectId}/experiments/{experimentId}: send only the fields you want to change. Requires experiments:write.

curl -X PATCH https://app.avsb.cloud/api/v1/projects/<projectId>/experiments/<experimentId> \  -H "Authorization: Bearer avsb_svc_..." \  -H "Content-Type: application/json" \  -d '{ "name": "Homepage hero v2", "trafficAlloc": 0.5 }'
Shell4 lines

Returns the updated experiment, same shape as List experiments. editorMode and experimentType are immutable after creation. Analysis-config fields (statsEngine, varianceReduction, customAlpha, …) are locked once the experiment leaves DRAFT/SCHEDULED. Mutations support X-Avsb-If-Match (or If-Match) for optimistic concurrency: see Conventions.

Trying to change an immutable field is refused, not silently ignored:

400: editorMode is immutable
{  "error": {    "code": "validation_failed",    "message": "editorMode is immutable once the experiment is created",    "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#validation-errors",    "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77"  }}
JSON8 lines

Delete an experiment

DELETE /api/v1/projects/{projectId}/experiments/{experimentId}: returns just the deleted id. Requires experiments:write.

Only a DRAFT or COMPLETED experiment can be deleted directly. Deleting a RUNNING, SCHEDULED or PAUSED experiment returns 400 validation_failed with details.forceable: true; repeat the request with ?force=true to delete it anyway. A forced delete of a live experiment republishes your project so visitors stop being bucketed into it.

curl -X DELETE https://app.avsb.cloud/api/v1/projects/<projectId>/experiments/<experimentId> \  -H "Authorization: Bearer avsb_svc_..."
Shell2 lines
Response
{ "data": { "id": "<experimentId>" } }
JSON1 line

Lifecycle

Each lifecycle endpoint is a POST that transitions the experiment's status and returns the updated experiment (200), in the same shape shown under List experiments. All require experiments:write.

Launch (or schedule)

POST .../experiments/{experimentId}/launch: launch immediately with an empty body, or schedule by passing a future start. The same call resumes a PAUSED experiment. A resume keeps the first launchedAt, so results still count from the original launch.

curl -X POST https://app.avsb.cloud/api/v1/projects/<projectId>/experiments/<experimentId>/launch \  -H "Authorization: Bearer avsb_svc_..."
Shell2 lines

To schedule instead of launching now, send a body:

Schedule a future launch
{  "schedulingEnabled": true,  "scheduledStartAt": "2026-07-01T09:00:00.000Z",  "endSpec": { "kind": "absolute", "scheduledEndAt": "2026-07-15T09:00:00.000Z" },  "timezone": "UTC"}
JSON6 lines

endSpec is one of { "kind": "none" }, { "kind": "absolute", "scheduledEndAt": "..." }, or { "kind": "relative", "days": 14, "timeOfDay": "09:00" }.

Launching while a future schedule is already active returns 409; cancel the schedule first:

409: already scheduled
{  "error": {    "code": "schedule_conflict",    "message": "Experiment is scheduled to launch at a future time. Cancel the schedule first.",    "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#conflict-errors",    "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77"  }}
JSON8 lines

A launch also checks that the experiment is actually ready, and so does scheduling one (see Schedule / Cancel schedule). Any of these returns 422 launch_precondition_failed, with the reason in details.reason:

Situationdetails.reasonMessage
A split URL experiment has no control URL or match mode setsplit_url_config_missing"Split URL experiment is missing control URL configuration"
A split URL experiment has no variant variationsplit_url_no_variants"Split URL experiment requires at least one variant variation"
A split URL variant has no destinationUrlsplit_url_missing_destination"Every variant must have a destinationUrl before launch"
A split URL variant would redirect back to the controlsplit_url_redirect_loopNames the variant and the loop
The experiment's code fails to compilecompilation_failed"Compilation failed" (the errors are in details.compilationErrors)
No metric is marked as the primaryprimary_metric_required"Add a primary metric before launching. An experiment with no primary metric has nothing to decide on."
Overall traffic allocation is 0%traffic_allocation_zero"Overall traffic is set to 0%, so no visitors would ever enter this experiment. Raise traffic allocation above 0% before launching."
A URL targeting rule cannot be readtargeting_rule_syntax_invalidNames the rule numbers
The project requires a completed analysis plan and this one does not have oneanalysis_plan_incomplete"This project requires a completed analysis plan before launch."

Pause / Stop / Archive

POST .../pause, POST .../stop, POST .../archive: each accepts an optional early-stop body.

Each action checks the experiment's current status first. Pause needs a RUNNING experiment; stop needs RUNNING or PAUSED; both return 400 validation_failed with details.reason: "invalid_status_transition", the current status and the allowed statuses when called from anywhere else, and change nothing.

Archive works from DRAFT, PAUSED and COMPLETED. It refuses RUNNING and SCHEDULED with 400 validation_failed and details.reason: "experiment_is_live", because archiving must never be the thing that changes what visitors see: call POST .../stop or POST .../pause first. An experiment that is already archived is refused with details.reason: "already_archived".

On success, these lifecycle actions (and launch, schedule, cancel-schedule, and a forced delete) may include a warning beside data when the change saved but publishing it to your live site did not finish. Your site may keep serving the previous version for a few minutes while the publish retries automatically; nothing is lost and no action is needed.

200 with a publish warning
{  "data": { "...": "..." },  "warning": {    "code": "datafile_publish_failed",    "message": "The status change is saved. Publishing it to your live site did not finish, so your site may keep serving the previous version for up to a few minutes while we retry."  }}
JSON7 lines
curl -X POST https://app.avsb.cloud/api/v1/projects/<projectId>/experiments/<experimentId>/pause \  -H "Authorization: Bearer avsb_svc_..." \  -H "Content-Type: application/json" \  -d '{ "earlyStop": true, "earlyStopReason": "clear winner reached" }'
Shell4 lines

stop and archive take the same body shape and return the same shape, at POST .../stop and POST .../archive. A third optional field, observedVisitors, carries the visitor count you have observed; the server uses it to double-check whether this really was an early stop rather than trusting the earlyStop flag alone.

  • pause moves the experiment to PAUSED.
  • stop completes it (COMPLETED).
  • archive files it away and changes nothing else. It sets archivedAt and leaves status, completedAt and the results exactly as they are, so an archived experiment keeps whatever state it was filed in.
Archive is not a stop

POST .../archive refuses a RUNNING or SCHEDULED experiment with details.reason: "experiment_is_live". If you were using archive to end a live test, call POST .../stop first, then archive. The archive body is still accepted for compatibility and is ignored: there is no early stop to record, because archiving does not end the experiment.

Unarchive

POST .../unarchive: restores an archived experiment. It clears archivedAt and resets nothing else, so the experiment comes back in the state it was filed in: a COMPLETED one comes back COMPLETED with its completedAt and its results, a PAUSED one comes back PAUSED. Nothing ever comes back RUNNING.

Any archived experiment can be restored. An experiment that is not archived is refused with 400 validation_failed and details.reason: "not_archived".

curl -X POST https://app.avsb.cloud/api/v1/projects/<projectId>/experiments/<experimentId>/unarchive \  -H "Authorization: Bearer avsb_svc_..."
Shell2 lines
400: not archived
{  "error": {    "code": "validation_failed",    "message": "This experiment is not archived, so there is nothing to restore.",    "details": {      "reason": "not_archived",      "currentStatus": "COMPLETED",      "archivedAt": null    },    "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#validation-errors",    "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77"  }}
JSON13 lines

Schedule / Cancel schedule

POST .../schedule creates or updates a schedule. Unlike the launch endpoint's optional scheduling fields, every field here is required (scheduledStartAt can be sent as null, but the key must be present).

curl -X POST https://app.avsb.cloud/api/v1/projects/<projectId>/experiments/<experimentId>/schedule \  -H "Authorization: Bearer avsb_svc_..." \  -H "Content-Type: application/json" \  -d '{ "scheduledStartAt": "2026-07-01T09:00:00.000Z", "endSpec": { "kind": "none" }, "timezone": "UTC" }'
Shell4 lines

endSpec takes the same three shapes as launch's scheduling body.

Scheduling a future launch for a DRAFT experiment runs the same readiness checks as launching it, because nothing checks again when the scheduled time arrives. An experiment with no primary metric, for example, answers 422 launch_precondition_failed with details.reason: "primary_metric_required", and no schedule is saved. This applies to POST .../schedule, to the scheduling body on POST .../launch, and to an update that sets a future scheduledStartAt. Rescheduling an experiment that is already SCHEDULED, or adding an end to one that is running, is not checked again.

Both endpoints can include the same warning shown under Pause / Stop / Archive above when the schedule change saved but republishing your live site did not finish.

POST .../cancel-schedule clears a schedule, optionally scoped:

curl -X POST https://app.avsb.cloud/api/v1/projects/<projectId>/experiments/<experimentId>/cancel-schedule \  -H "Authorization: Bearer avsb_svc_..." \  -H "Content-Type: application/json" \  -d '{ "scope": "both" }'
Shell4 lines

scope is launch, end, or both (default both), and the whole body is optional.

Next steps

Was this helpful?