Public API: Projects
A project is the container everything else lives in: experiments, flags, metrics, audiences, orders. Every other project-scoped path needs a {projectId}, so this is the resource an integration starts from.
The org is taken from the token, so the path carries no {orgId}. A token can only see projects in its own organization.
https://app.avsb.cloud/api/v1/projectsEvery endpoint here follows the shared conventions: the { data } envelope, an Idempotency-Key on both writes below, and the standard error shape. Every /api/v1 token is rate-limited the same way everywhere: a scoped token gets 600 reads and 120 writes per minute, and an admin:* token gets 600 requests per minute, reads and writes together. Every endpoint here also needs the Integrations and API access plan feature, or every call fails with 403 feature_disabled. See rate-limit headers and Refusals at 403.
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 projects | projects:read |
| Create / update a project | projects:write |
List projects
GET /api/v1/projects: every project in the organization, newest-created first, cursor-paginated.
curl "https://app.avsb.cloud/api/v1/projects?limit=20" \ -H "Authorization: Bearer avsb_svc_..."const res = await fetch('https://app.avsb.cloud/api/v1/projects?limit=20', { headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` },})const { data, page } = await res.json()console.log(data.length, page.hasMore)import os, requestsres = requests.get( "https://app.avsb.cloud/api/v1/projects", params={"limit": 20}, headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)body = res.json()projects, page = body["data"], body["page"]{ "data": [ { "id": "clxyz456def", "shortId": 200001, "orgId": "clorg001", "name": "Marketing site", "description": null, "url": "https://example.com", "environment": "PRODUCTION", "type": "WEB_EXPERIMENTATION", "status": "ACTIVE", "currency": "USD", "snippetKey": "snp_abc123", "snippetDetectedAt": "2026-06-20T09:14:00.000Z", "attributionWindowDays": 7, "attributionWindowHours": 0, "spaHashRouting": false, "responsiveMode": false, "antiFlicker": true, "consentMode": false, "consentCookieClass": "FUNCTIONAL", "readGoogleConsent": false, "autoCollectDataLayer": false, "isOnlineStore": null, "profitIncludesShipping": false, "shopifyCogsSyncEnabled": true, "defaultStatsEngine": "BAYESIAN", "defaultVarianceReduction": "AUTO", "requiresAnalysisPlan": false, "createdAt": "2026-06-18T00:00:00.000Z", "updatedAt": "2026-06-19T00:00:00.000Z" } ], "page": { "nextCursor": null, "hasMore": false }}Pass ?limit= (1 to 100, default 20) and the opaque ?cursor= from page.nextCursor to page through results.
Get a project
GET /api/v1/projects/{projectId}: {projectId} accepts either the project id or its numeric shortId, so the number in your dashboard URL works here too. Returns the same shape as List projects above, for one project.
curl https://app.avsb.cloud/api/v1/projects/200001 \ -H "Authorization: Bearer avsb_svc_..."const res = await fetch('https://app.avsb.cloud/api/v1/projects/200001', { headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` },})const { data } = await res.json()console.log(data.name)import os, requestsres = requests.get( "https://app.avsb.cloud/api/v1/projects/200001", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data = res.json()["data"]The response carries a weak ETag. Pass it back as X-Avsb-If-Match on a later PATCH to detect a concurrent edit. A projectId that does not exist, or belongs to another organization, answers 404 not_found either way: the API never reveals whether a project exists in someone else's org.
Create a project
POST /api/v1/projects
The smallest request only needs a name and a URL. type defaults to WEB_EXPERIMENTATION, and a URL is required for that type:
curl -X POST https://app.avsb.cloud/api/v1/projects \ -H "Authorization: Bearer avsb_svc_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "name": "Marketing site", "url": "https://example.com" }'const res = await fetch('https://app.avsb.cloud/api/v1/projects', { method: 'POST', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify({ name: 'Marketing site', url: 'https://example.com' }),})const { data } = await res.json()import os, uuid, requestsres = requests.post( "https://app.avsb.cloud/api/v1/projects", headers={ "Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}", "Idempotency-Key": str(uuid.uuid4()), }, json={"name": "Marketing site", "url": "https://example.com"},)data = res.json()["data"]A fuller request for a feature-flag project, where the URL is optional and you name the environment explicitly:
curl -X POST https://app.avsb.cloud/api/v1/projects \ -H "Authorization: Bearer avsb_svc_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "name": "Checkout flags", "type": "FEATURE_FLAG", "environment": "STAGING", "description": "Flags for the new checkout rollout" }'Returns 201 with the created project, in the same shape as List projects above.
Field notes
typeisWEB_EXPERIMENTATION(default) orFEATURE_FLAG.urlis required for web-experimentation projects and optional for feature-flag projects. It must start withhttp://orhttps://; any other scheme (such asftp://) is refused with400, on create and on update.environmentisPRODUCTION(default),STAGING, orDEVELOPMENT. It labels the project; it is not the same thing as a feature-flag environment.namecan be up to 200 characters, anddescriptionandurlup to 2000 each. Omitdescriptionand it comes back as an empty string, notnull.isOnlineStoreanswers "is this project an online store?". Sendtrueorfalseto record the answer; leave it out and it staysnull, meaning nobody has been asked yet, and the dashboard keeps offering to set up commerce. It is never turned intofalseon your behalf, because "not a store" and "not asked" are different answers. Accepted on feature-flag projects too: it describes the business, not the project type.- A feature-flag project is created with two flag environments, Production and Development, each with its own SDK key. Read them back from the environments API.
- A web-experimentation project gets its first datafile published immediately, so the snippet has something to fetch the moment you add the tag.
- Creating a project counts against your plan's project limit. At the limit the call returns
403with afeature_disablederror.
Error responses
Omit url on a web-experimentation project (the default type) and validation catches it before anything is created:
{ "error": { "code": "validation_failed", "message": "Request body failed validation", "details": { "issues": [ { "param": "url", "path": ["url"], "code": "custom", "message": "URL is required for web experimentation projects" } ] } }}At your plan's project limit, the same call instead answers:
{ "error": { "code": "feature_disabled", "message": "You have reached your plan's limit. Upgrade to add more.", "details": { "kind": "quota", "resource": "projects", "limit": 3, "current": 3 } }}Update a project
PATCH /api/v1/projects/{projectId}: send only the fields you want to change.
curl -X PATCH https://app.avsb.cloud/api/v1/projects/200001 \ -H "Authorization: Bearer avsb_svc_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "status": "PAUSED", "antiFlicker": false }'Fields you can change:
| Field | Notes |
|---|---|
name, description, url | Project identity. |
environment | PRODUCTION, STAGING, DEVELOPMENT. |
status | ACTIVE, PAUSED, ARCHIVED. Pausing stops the snippet serving experiments. |
currency | Three-letter ISO 4217 code, uppercase (for example USD). |
attributionWindowDays, attributionWindowHours | How long after an exposure a conversion still counts. Days: 0 to 90. Hours: 0 to 23. |
spaHashRouting | Whether a change in the URL fragment (#/checkout) counts as a new page view. Single-page-app support itself is always on: the snippet follows history navigation without being told to, and this switch only adds hash handling. In the dashboard this is the Hash-based routing toggle under project settings. |
responsiveMode, antiFlicker | Snippet behaviour flags. |
consentMode, consentCookieClass, readGoogleConsent | Consent handling. consentCookieClass is FUNCTIONAL or ANALYTICS. |
autoCollectDataLayer | Whether the snippet reads your data layer automatically. In the dashboard this is the Read purchases from your data layer toggle under project settings. |
isOnlineStore | Whether this project is an online store. true or false only: the API records an answer, it cannot put the question back to unanswered. Same setting as the dashboard's store toggle, and it lands in your audit log the same way. |
profitIncludesShipping | Whether shipping cost is taken off profit alongside the cost of goods. Applies from the next nightly profit run onward; orders already worked out keep the profit they were given. In the dashboard this is on the Profit settings card under Commerce, Orders & attribution. |
shopifyCogsSyncEnabled | Whether the nightly Shopify sync keeps your product costs up to date. On by default, and inert on a project with no Shopify connection. Same Profit settings card in the dashboard. |
Any change to a snippet-visible setting republishes the project's datafile, so it reaches visitors on their next fetch. Changing currency or autoCollectDataLayer also re-seeds the built-in revenue metrics. The two profit fields are read by the nightly jobs rather than the snippet, so changing them republishes nothing.
Sending a field that is not in the table returns 400 validation_failed rather than being ignored, so a typo is never a silent no-op. The visual editor's project script, shared type definitions, brand palette, internal-traffic rules, and the statistical analysis defaults are dashboard-only and are not accepted here. Allowed origins have their own endpoint.
Error response
Send a stale X-Avsb-If-Match (from an earlier GET) and the write is refused before anything changes:
{ "error": { "code": "precondition_failed", "message": "The resource changed since you read it. Re-read it and retry.", "details": { "expected": "W/\"a1b2c3d4e5f60798\"" } }}Deleting a project
There is no delete endpoint. Deleting a project removes every experiment, flag, metric, and stored result underneath it, which is not an operation to hand to a retrying script on the strength of one token. Set status to ARCHIVED here, and delete from the dashboard if you really mean it.