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}.
https://app.avsb.cloud/api/v1/projects/{projectId}/experimentsIdentifiers 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.
| Operation | Scope |
|---|---|
| List / get experiments | experiments:read |
| Create / update / delete | experiments: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_..."const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/experiments?limit=20`const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` } })const { data, page } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"res = requests.get( f"https://app.avsb.cloud/api/v1/projects/{project_id}/experiments", params={"limit": 20}, headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data, page = res.json()["data"], res.json()["page"]Every experiment carries the same fields, whether it comes back from this list, from Get an experiment, or from a write below:
{ "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 }}An out-of-range limit, or a cursor that is not a value this API produced, is refused rather than silently clamped:
{ "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" }}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_..."const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const experimentId = 'cly9z8x7w6v5u4t3s2r1q0p9'const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/experiments/${experimentId}`const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` } })const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"experiment_id = "cly9z8x7w6v5u4t3s2r1q0p9"res = requests.get( f"https://app.avsb.cloud/api/v1/projects/{project_id}/experiments/{experiment_id}", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data = res.json()["data"]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":
{ "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" }}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" }'const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const res = await fetch(`https://app.avsb.cloud/api/v1/projects/${projectId}/experiments`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Homepage hero' }),})const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"res = requests.post( f"https://app.avsb.cloud/api/v1/projects/{project_id}/experiments", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"}, json={"name": "Homepage hero"},)data = res.json()["data"]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>" }'const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const res = await fetch(`https://app.avsb.cloud/api/v1/projects/${projectId}/experiments`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ 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>', }),})const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"res = requests.post( f"https://app.avsb.cloud/api/v1/projects/{project_id}/experiments", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"}, json={ "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>", },)data = res.json()["data"]descriptionis free text for your own team; leave it out and it is stored as an empty string.trafficAllocis the share of matching visitors who enter the experiment at all, from0to1.variationslists 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). Theirweightvalues must sum to1.0(a small rounding tolerance is allowed). The same rule applies when you sendvariationson an update, and an A/A test (isAATest: true) takes exactly 2.audienceIdsandmetricIdsattach existing audiences and metrics by id.metricIdstakes metric binding ids (the same ids the metric bindings API returns), not raw metric ids.primaryMetricIdmust be one of the ids inmetricIds, 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".
New experiments start in DRAFT. Use the lifecycle endpoints below to launch them; status is never set through create or update.
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:
{ "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" }}Variation weights that do not add up to 1.0 are refused the same way, with the sum you sent:
{ "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" }}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 }'const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const experimentId = 'cly9z8x7w6v5u4t3s2r1q0p9'const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/experiments/${experimentId}`const res = await fetch(url, { method: 'PATCH', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Homepage hero v2', trafficAlloc: 0.5 }),})const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"experiment_id = "cly9z8x7w6v5u4t3s2r1q0p9"res = requests.patch( f"https://app.avsb.cloud/api/v1/projects/{project_id}/experiments/{experiment_id}", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"}, json={"name": "Homepage hero v2", "trafficAlloc": 0.5},)data = res.json()["data"]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:
{ "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" }}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_..."const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const experimentId = 'cly9z8x7w6v5u4t3s2r1q0p9'const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/experiments/${experimentId}`const res = await fetch(url, { method: 'DELETE', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` } })const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"experiment_id = "cly9z8x7w6v5u4t3s2r1q0p9"res = requests.delete( f"https://app.avsb.cloud/api/v1/projects/{project_id}/experiments/{experiment_id}", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data = res.json()["data"]{ "data": { "id": "<experimentId>" } }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_..."const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const experimentId = 'cly9z8x7w6v5u4t3s2r1q0p9'const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/experiments/${experimentId}/launch`const res = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` } })const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"experiment_id = "cly9z8x7w6v5u4t3s2r1q0p9"res = requests.post( f"https://app.avsb.cloud/api/v1/projects/{project_id}/experiments/{experiment_id}/launch", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data = res.json()["data"]To schedule instead of launching now, send a body:
{ "schedulingEnabled": true, "scheduledStartAt": "2026-07-01T09:00:00.000Z", "endSpec": { "kind": "absolute", "scheduledEndAt": "2026-07-15T09:00:00.000Z" }, "timezone": "UTC"}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:
{ "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" }}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:
| Situation | details.reason | Message |
|---|---|---|
| A split URL experiment has no control URL or match mode set | split_url_config_missing | "Split URL experiment is missing control URL configuration" |
| A split URL experiment has no variant variation | split_url_no_variants | "Split URL experiment requires at least one variant variation" |
A split URL variant has no destinationUrl | split_url_missing_destination | "Every variant must have a destinationUrl before launch" |
| A split URL variant would redirect back to the control | split_url_redirect_loop | Names the variant and the loop |
| The experiment's code fails to compile | compilation_failed | "Compilation failed" (the errors are in details.compilationErrors) |
| No metric is marked as the primary | primary_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 read | targeting_rule_syntax_invalid | Names the rule numbers |
| The project requires a completed analysis plan and this one does not have one | analysis_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.
{ "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." }}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" }'const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const experimentId = 'cly9z8x7w6v5u4t3s2r1q0p9'const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/experiments/${experimentId}/pause`const res = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ earlyStop: true, earlyStopReason: 'clear winner reached' }),})const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"experiment_id = "cly9z8x7w6v5u4t3s2r1q0p9"res = requests.post( f"https://app.avsb.cloud/api/v1/projects/{project_id}/experiments/{experiment_id}/pause", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"}, json={"earlyStop": True, "earlyStopReason": "clear winner reached"},)data = res.json()["data"]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
archivedAtand leavesstatus,completedAtand the results exactly as they are, so an archived experiment keeps whatever state it was filed in.
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_..."const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const experimentId = 'cly9z8x7w6v5u4t3s2r1q0p9'const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/experiments/${experimentId}/unarchive`const res = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` } })const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"experiment_id = "cly9z8x7w6v5u4t3s2r1q0p9"res = requests.post( f"https://app.avsb.cloud/api/v1/projects/{project_id}/experiments/{experiment_id}/unarchive", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data = res.json()["data"]{ "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" }}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" }'const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const experimentId = 'cly9z8x7w6v5u4t3s2r1q0p9'const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/experiments/${experimentId}/schedule`const res = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ scheduledStartAt: '2026-07-01T09:00:00.000Z', endSpec: { kind: 'none' }, timezone: 'UTC' }),})const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"experiment_id = "cly9z8x7w6v5u4t3s2r1q0p9"res = requests.post( f"https://app.avsb.cloud/api/v1/projects/{project_id}/experiments/{experiment_id}/schedule", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"}, json={"scheduledStartAt": "2026-07-01T09:00:00.000Z", "endSpec": {"kind": "none"}, "timezone": "UTC"},)data = res.json()["data"]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" }'const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const experimentId = 'cly9z8x7w6v5u4t3s2r1q0p9'const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/experiments/${experimentId}/cancel-schedule`const res = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ scope: 'both' }),})const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"experiment_id = "cly9z8x7w6v5u4t3s2r1q0p9"res = requests.post( f"https://app.avsb.cloud/api/v1/projects/{project_id}/experiments/{experiment_id}/cancel-schedule", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"}, json={"scope": "both"},)data = res.json()["data"]scope is launch, end, or both (default both), and the whole body is optional.