Public API: Conventions
Every endpoint in the AvsB public API obeys the same wire-format contract: envelope shape, HTTP status codes, retry-safe writes via Idempotency-Key, optimistic concurrency via If-Match / ETag, rate-limit headers, opaque cursor pagination, and URL-path API versioning. This page is the terse reference: keep it open while integrating.
Response envelope
Successful responses use a data wrapper:
{ "data": { "id": "tok_abc", "name": "Terraform CI" } }List endpoints nest cursor pagination under a page object:
{ "data": [ ... ], "page": { "nextCursor": "eyJpZCI6ImFiYyIsInNvcnRWYWx1ZSI6IjIwMjYtMDUtMTciLCJzY2hlbWFWZXJzaW9uIjoxfQ", "hasMore": true }}Errors use a structured error wrapper:
{ "error": { "code": "scope_missing", "message": "Token is missing required scope: experiments:write", "details": { "missingScope": "experiments:write" }, "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#refusals-at-403", "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77" }}code is the stable machine value: switch on it, never on the message. docUrl
points at the section of this page that explains the code. requestId matches the
X-Request-Id header on the same response, so quoting it in a support message is
enough for us to find the exact request. details is present only when there is
something structured to say.
Every response, success or error, carries X-Request-Id. If you send your own
X-Request-Id header and it is between 8 and 64 characters of letters, digits,
- or _, we adopt it, so your trace id and ours are the same string.
curl -i https://app.avsb.cloud/api/v1/projects \ -H "Authorization: Bearer avsb_svc_..." \ -H "X-Request-Id: my-trace-01H8XK"# < X-Request-Id: my-trace-01H8XKHTTP status codes
The API uses a fixed, predictable set of status codes:
200 OK: successful read, update, action, or idempotent replay.201 Created: successful resource creation.400 Bad Request: the request body or a query parameter failed validation.401 Unauthorized: missing, invalid, expired, or revoked credential.403 Forbidden: authenticated but lacking a required scope (error codescope_missing) or refused by your organization's access rules (error codefeature_disabled, see Refusals at 403). Also used for one-off business refusals, like deleting a built-in role (error codeforbidden).404 Not Found: resource does not exist, is outside the org, or was removed between your read and your write. A path under/api/v1that matches no endpoint at all answers404too, in the same JSON envelope.405 Method Not Allowed: the path exists, but not for this HTTP method. TheAllowheader, anddetails.allowin the body, list the methods the path does serve.409 Conflict: idempotency-key collision on a different body, a schedule collision, variation code that has not been compiled yet, a unique value another record already holds, or a resource in a state that forbids the operation.412 Precondition Failed: the conditional-write ETag (X-Avsb-If-Match, orIf-Match) did not match, and nothing else.413 Payload Too Large: the request body is over 4 MB (error codevalidation_failed,details.reason: "body_too_large"). Above about 4.5 MB the hosting platform refuses the request before it reaches the API, with a plain-text413instead of the JSON envelope.422 Unprocessable Entity: the request was understood, but the resource is not ready for it (for example launching an experiment with a missing control URL).429 Too Many Requests: rate limit exhausted, or an action repeated too soon; wait forRetry-After(seconds) before retrying.500 Internal Server Error: unexpected server fault; safe to retry.503 Service Unavailable: temporarily unavailable, including planned maintenance. See Maintenance windows.
Deletes return 200 with a body ({"data":{"id":"..."}} or {"data":null}), not
204. POST responses do not set a Location header: the created resource is in
the body.
Error codes
Every error carries a code from this fixed list and a docUrl pointing at the
section that explains it. New codes can be added; existing ones never change
meaning.
| Code | Status | Meaning |
|---|---|---|
unauthorized | 401 | No credential, or one we do not recognise. |
token_expired | 401 | The credential was valid and has passed its expiry. |
token_revoked | 401 | The credential was revoked. It cannot be restored. |
scope_missing | 403 | Authenticated, but the credential lacks a required scope. |
forbidden | 403 | Authenticated and scoped, but not allowed to do this. |
feature_disabled | 403 | Refused by your organization's plan, quota, or status. |
not_found | 404 | No such resource in your organization, or it is already gone. |
validation_failed | 400 | The body or a parameter did not pass validation. |
pagination_invalid | 400 | Bad limit or a malformed cursor. |
version_unsupported | 400 | An API version was requested that we do not serve. |
method_not_allowed | 405 | Wrong HTTP method for this path. |
precondition_failed | 412 | X-Avsb-If-Match (or If-Match) did not match the current ETag. |
launch_precondition_failed | 422 | The resource is not ready to go live. |
dataset_state_conflict | 409 | The resource is in a state that forbids this. |
conflict | 409 | A value another record already holds, such as a key or a name. details.field names it. |
idempotency_conflict | 409 | Idempotency-Key reused on a different request. |
idempotency_in_progress | 409 | A request with the same Idempotency-Key is still running. Retry with the same key. |
schedule_conflict | 409 | A schedule already exists that this would race. |
compile_output_missing | 409 | Variation code has not been compiled yet. |
rate_limited | 429 | Rate limit exhausted. Wait for Retry-After. |
preview_unavailable | 503 | Preview rendering is temporarily unavailable. |
results_unavailable | 503 | Results are temporarily unavailable. Retry. |
service_unavailable | 503 | AvsB is temporarily unavailable, including maintenance. |
internal_error | 500 | Unexpected fault on our side. Safe to retry. |
Authentication errors
unauthorized, token_expired and token_revoked all answer 401, and the code
tells you which of the three it is, so a failing integration does not need guesswork:
unauthorized: the header is missing, malformed, or the credential is unknown. Check you are sendingAuthorization: Bearer <token>and that the token starts withavsb_svc_oravsb_pat_.token_expired: create a new token, or finish the rotation you started (see rotation).token_revoked: the token was revoked deliberately. Issue a new one.
Validation errors
validation_failed carries a details.issues array. Each issue names the field and
why it failed, in machine terms, so you can map it to a form field without reading
the message:
{ "error": { "code": "validation_failed", "message": "Request body failed validation", "details": { "issues": [ { "param": "variations.0.key", "path": ["variations", 0, "key"], "code": "too_small", "message": "String must contain at least 1 character(s)", "limit": 1, "expected": "string" } ] } }}param is the dotted path, path is the same thing as segments (array indices stay
numbers), and code is the machine reason: invalid_type, too_small, too_big,
invalid_enum_value, unrecognized_keys, invalid_string, custom and friends.
expected, received, options, keys and limit appear when they apply.
Not-found errors
not_found means "no such resource in your organization". A resource that exists
in another organization answers exactly the same way, on purpose: a 404 must not tell
you whether something exists elsewhere.
A path under /api/v1 that matches no endpoint (a typo, say) answers not_found as well, with the method and path you sent named in the message, never an HTML page.
A resource that disappeared between your read and your write answers not_found too.
If you delete or update something that someone else has already removed, you get 404,
not a 500. Retrying will not change the answer.
Precondition errors
launch_precondition_failed (422) means the request was understood but the resource
is not ready. Launching a split-URL experiment with no control URL is the common
case. The message names the missing piece. Retrying without changing anything gives
the same answer.
This is deliberately NOT 412: 412 means only "your ETag is stale, re-read and
retry", so a client can treat those two situations differently without parsing prose.
Conflict errors
409 covers six codes, each with a distinct meaning:
idempotency_conflict: the sameIdempotency-Keywas reused on a different request.idempotency_in_progress: the first request with thisIdempotency-Keyis still running. This is the one409to retry unchanged: wait forRetry-After, then send the same request with the same key and you get the first request's response.schedule_conflict: a pending schedule would race this change.conflict: a value you sent is already taken by another record (a key, a name, an event key).details.fieldnames the field when it can be said for certain, and some resources add the record that holds it (a metric conflict carriesdetails.conflict). Change the value and send again.dataset_state_conflict: the resource's current state forbids the operation, for example committing a version that is not uploading, or deleting a system dataset.compile_output_missing: a variation's code has not been compiled yet, so there is nothing to publish.
Every 409 is worth reading before you retry: the same request will keep failing until
something changes. A duplicate value needs a different value, a stale key needs a new one.
Availability errors
preview_unavailable, results_unavailable and service_unavailable are all 503
and all mean "try again later, unchanged". They are the only errors besides 429
worth an automatic retry.
Server errors
internal_error (500) is a fault on our side. Retry with backoff, and if it persists
send us the requestId from the response: it identifies the exact request in our
logs.
Refusals at 403
Every access refusal shares one error code, feature_disabled, at 403. The reason rides in details.kind, so a client can tell them apart without parsing the message. Feature-related refusals also carry details.feature naming the feature that was refused. quota carries details.resource, details.limit and details.current instead, upgrade_required carries details.upgradeUrl, and usage_paused carries details.reason.
details.kind | What happened | What to do |
|---|---|---|
suspended | The organization is suspended. Every endpoint refuses, reads included. A different token does not help. | Contact support. Billing and privacy endpoints stay open so the org can settle up. |
quota | A create request hit a plan limit. details also carries resource (seats or projects), limit, and current. | Free up capacity or upgrade. Retrying is pointless. |
plan | The feature is not included for the organization. Usually that is the AI copilot or the Assistant, which need billing details on file. | Add billing details under Settings, Billing. If an agreement with us covers your usage, contact support. |
unavailable | The feature is switched off for this organization right now. | Contact support. Retrying is pointless. |
admin | The feature is not enabled for this organization. | Contact support. |
upgrade_required | The organization's traffic has outgrown its plan for two months running, and this request would create or launch an experiment. Experiments already running keep running. | Upgrade the plan. |
usage_paused | Delivery to the organization's site is paused. details.reason names why, and the message is one of:NO_BILLING_DETAILS: "Delivery is paused for your organization until billing details are added. Add them under Settings, Billing (/settings?tab=billing)."SPEND_LIMIT: "Delivery is paused for your organization until the spend limit is raised or next month starts. Change it under Settings, Billing (/settings?tab=billing)."UNPAID: "Delivery is paused for your organization until the card on file is updated. Update it under Settings, Billing (/settings?tab=billing)." | Read details.reason and act on it: add billing details, raise the spend limit, or update the card. Retrying without doing that is pointless. |
{ "error": { "code": "feature_disabled", "message": "You have reached your plan's limit. Upgrade to add more.", "details": { "kind": "quota", "resource": "projects", "limit": 1, "current": 1 } }}A 403 with kind: "suspended", "unavailable", "quota", or "usage_paused" is not transient. Do not put it in a retry loop: surface it to a human instead. Only 429 and 503 are worth retrying automatically.
Maintenance windows
During planned maintenance the API answers 503 with a Retry-After header saying how many seconds to wait. It comes from the edge, ahead of routing, but it uses the same error envelope as everything else, so one parser covers it:
{ "error": { "code": "service_unavailable", "message": "The service is temporarily down for maintenance.", "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#maintenance-windows" }}Wait for Retry-After before retrying. Maintenance affects AvsB itself: experiments and flags already published keep running on your own site, and event collection keeps working.
Idempotency-Key
Every state-changing request (POST / PUT / PATCH / DELETE) accepts an optional Idempotency-Key header. Supply a unique value (UUID, ULID, or any string of 1 to 255 characters) per logical request. A longer key is refused with 400 validation_failed, and an empty header counts as no key. Keys belong to the token that sent them: the same key from a different token is a different key.
Semantics within the 24-hour window:
- First call: processed normally, response stored with a fingerprint of the request: its method, its full URL and its body.
- Replay (same key, same request): returns the stored response with its ORIGINAL status (
201for a create,200for an update or a delete) plus anIdempotency-Replayed: trueheader, so no side effect runs twice. A replayedDELETEanswers its original200, not a404for the record it already removed. - Conflict (same key, different request): returns
409with error codeidempotency_conflict. Pick a new key. Two deletes of different records with one key count as different requests. - Still running (same key, sent while the first request is in flight): the second request waits for the first, up to about five seconds, and returns its response. If the first is still running after that, the answer is
409 idempotency_in_progresswithRetry-After: retry with the same key. - Errors are not stored: a request that fails (a
400, a404, a500) leaves the key free, so a corrected retry with the same key runs. - Secrets are not stored: a replayed response never repeats a signing secret. On a replayed webhook create or secret rotation,
secretisnull; if you lost the original response, rotate the secret.
# Initial createcurl https://app.avsb.cloud/api/v1/projects \ -X POST \ -H "Authorization: Bearer avsb_svc_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"name":"Checkout"}'# Retrying with the same key returns the same response, no duplicate created# Response header: Idempotency-Replayed: trueconst key = crypto.randomUUID()await fetch('https://app.avsb.cloud/api/v1/projects', { method: 'POST', headers: { 'Authorization': 'Bearer avsb_svc_...', 'Content-Type': 'application/json', 'Idempotency-Key': key, }, body: JSON.stringify({ name: 'Checkout' }),})A retry after a timeout is the same request, not a second one. Send the sameIdempotency-Keyand the second call returns the first call's answer instead of doing the work again. Run both commands and compare the headers:
KEY=$(uuidgen)
# 1. First call: runs, creates the project, stores the response.
curl -i https://app.avsb.cloud/api/v1/projects \
-X POST \
-H "Authorization: Bearer avsb_svc_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d '{"name":"Checkout"}'
# 2. Same key, same body: nothing runs, the stored response comes back.
curl -i https://app.avsb.cloud/api/v1/projects \
-X POST \
-H "Authorization: Bearer avsb_svc_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d '{"name":"Checkout"}'
# < Idempotency-Replayed: true| First call | Same key again | |
|---|---|---|
| HTTP status | 201 Created | 201 Created (the stored one) |
| Response body | The new project | Byte-identical to the first |
| Idempotency-Replayed | absent | true |
| Projects created | One | None |
The window is 24 hours. After it expires the key is forgotten and the same request runs again. Sending the same key with a different body is the one case that refuses:409 idempotency_conflict, so a key can never return an answer to a question you did not ask.
ETag / If-Match
Individual-resource GET responses for the core platform resources carry a weak ETag header, derived from the resource's updatedAt timestamp and identifier. The ETag-bearing resources are projects, experiments, flags, flag rules, flag attributes, environments, audiences, segments, metrics, metric bindings, exclusion groups, webhooks, and allowed origins.
Pass that value back as X-Avsb-If-Match on a write to one of those resources and it is enforced. The check runs before anything is written: a stale token is refused with 412 and the resource is untouched. The 412 body carries details.expected, the ETag the resource has now, so a client can compare without a second read.
The standard If-Match header is accepted too, and X-Avsb-If-Match is the one to use, because intermediaries such as proxies, caches and edge networks are allowed to act on the standard conditional header before the request reaches the API.
Three kinds of write stay unconditional, and say so rather than accepting a header they would ignore:
- Roles. The underlying record keeps no modification timestamp, so there is no version to compare. Role updates are last-write-wins.
- Project integrations, and replacing a flag's whole variation set. Each request covers a set of rows rather than one resource, so no single version can answer for it.
- Commerce resources (catalog, datasets, recommendations, orders).
Writes that send neither header succeed unconditionally: opt-in to optimistic concurrency only where you need it.
# 1. Readcurl -i https://app.avsb.cloud/api/v1/projects/proj_123# < ETag: W/"a1b2c3d4e5f60798"# 2. Update: succeeds only if no one else has written since the readcurl -X PATCH https://app.avsb.cloud/api/v1/projects/proj_123 \ -H "Authorization: Bearer avsb_svc_..." \ -H "Content-Type: application/json" \ -H "X-Avsb-If-Match: W/\"a1b2c3d4e5f60798\"" \ -d '{"name":"Checkout v2"}'# 412 Precondition Failed if the resource has changed since the readconst url = 'https://app.avsb.cloud/api/v1/projects/proj_123'const token = 'Bearer avsb_svc_...'const read = await fetch(url, { headers: { Authorization: token } })const etag = read.headers.get('ETag')const body = (await read.json()) as { data: { name: string } }const project = body.dataconst write = await fetch(url, { method: 'PATCH', headers: { Authorization: token, 'Content-Type': 'application/json', 'X-Avsb-If-Match': etag ?? '', }, body: JSON.stringify({ name: project.name + ' v2' }),})if (write.status === 412) { // someone else updated; re-read and decide}Rate-limit headers
Every response carries the rate-limit headers, success and error alike, including 403 scope_missing, 400 validation_failed and 404 answers:
X-RateLimit-Limit: requests permitted per minute.X-RateLimit-Remaining: requests left in the current window.X-RateLimit-Reset: unix timestamp (seconds) when the window resets.Retry-After: present only on429responses; seconds to wait.
On a 401 there is no authenticated token to describe, so the headers describe the
per-IP authentication-failure bucket instead. That is the limit a client sending bad
credentials is actually up against.
# Inspect rate-limit state from any responsecurl -i https://app.avsb.cloud/api/v1/projects \ -H "Authorization: Bearer avsb_svc_..."# < X-RateLimit-Limit: 600# < X-RateLimit-Remaining: 597# < X-RateLimit-Reset: 1747526400const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))const res = await fetch('https://app.avsb.cloud/api/v1/projects', { headers: { Authorization: 'Bearer avsb_svc_...' },})const remaining = Number(res.headers.get('X-RateLimit-Remaining'))if (remaining < 10) { await sleep(Number(res.headers.get('X-RateLimit-Reset')) * 1000 - Date.now())}Try it with your own credential
Every promise on this page is observable. Pick a read, paste a personal access token or a service token, and the console runs it against the real API and shows what came back, headers included.
Try it
Runs against the real API with your own credential. Read-only samples only, and the credential is used for this one call: it is never stored.
Every project in the organization your credential belongs to.
curl https://app.avsb.cloud/api/v1/projects \ -H 'Authorization: Bearer YOUR_TOKEN'
The console runs read-only samples only: it never creates, changes, or deletes anything in your organization. Your credential goes to our own server for that one call, is used to authenticate it, and is not stored.
Cursor pagination
List endpoints page via an opaque cursor. Read page.nextCursor from one response and pass it back as the cursor query parameter to fetch the next page; page.hasMore tells you when the last page has been reached. Treat the cursor as a black-box string: never parse, mutate, or build one yourself; its internal encoding is not part of the contract and can change without notice. limit defaults to 20 and caps at 100.
# Page 1curl https://app.avsb.cloud/api/v1/projects/<projectId>/experiments?limit=50 \ -H "Authorization: Bearer avsb_svc_..."# → { "data": [ ... ], "page": { "nextCursor": "eyJpZ...", "hasMore": true } }# Page 2: pass the previous page.nextCursor verbatim as `cursor`curl 'https://app.avsb.cloud/api/v1/projects/<projectId>/experiments?limit=50&cursor=eyJpZ...' \ -H "Authorization: Bearer avsb_svc_..."Cursors are opaque. A malformed or truncated cursor (or a limit outside the 1–100 range) is rejected with a 400 and the error code pagination_invalid. Start over from the first page (drop the cursor parameter) if that happens.
Path parameters: short or canonical IDs
Every *Id path parameter on the API accepts either the canonical cuid (e.g. cmnoa4mfm000209l4dzq85lqf) or the short numeric ID shown in the dashboard URL (e.g. 200008). Both forms address the same resource, so you can copy whichever string is in front of you.
# These two requests target the same project and return the same data:curl https://app.avsb.cloud/api/v1/projects/cmnoa4mfm000209l4dzq85lqf \ -H "Authorization: Bearer avsb_svc_..."curl https://app.avsb.cloud/api/v1/projects/200008 \ -H "Authorization: Bearer avsb_svc_..."Checking whether you are signed in…
Audit-log entries, ETag values, and webhook payloads always reference the canonical cuid form. The short ID is a path-parameter convenience only; request bodies must use the canonical cuid where they reference foreign resources.
Parameters that accept either form: projectId, experimentId, variationId,
flagId, audienceId, segmentId, metricId, metricBindingId, groupId,
datasetId, recipeId.
Four take the canonical id only, because the resources behind them have no short
numeric id at all: webhookId, roleId, envId, attrId. ruleId is also
canonical-only.
The organization is resolved from your credential, so there is no orgId path
parameter on the public API.
API versioning
The public API is versioned in the URL path: every endpoint lives under /api/v1. There is a single live version, v1, so there is nothing to pin today.
Every response is stamped with the version that served it:
AvsB-API-Version: v1For forward compatibility the API also accepts an AvsB-API-Version request header, but on v1 it has no effect: sending it or omitting it returns the identical contract, and the response is stamped v1 either way. Read the response header, do not rely on the request one.
# The request header has no effect on v1; the response is stamped regardless.curl -i https://app.avsb.cloud/api/v1/projects \ -H "Authorization: Bearer avsb_svc_..."# < AvsB-API-Version: v1If a future release introduces a breaking change it will ship under a new URL prefix (for example /api/v2) and leave /api/v1 serving its current contract, so existing integrations keep working without changes.