Public API: Metrics
A metric is a tracking definition: it says what behaviour to capture. There are three kinds. A Click watches a CSS selector. A Pageview watches for a matching URL. A Custom metric watches for an event key you send yourself, optionally carrying a number.
Experiments and feature-flag rules reference metrics to measure their impact.
Metrics belong to your whole organization, not to one project. So this API has no {orgId} or {projectId} in its path: your service token already names the org. Each metric can still have one home project, or none. Send a projectId string to give it a home project, or null to make it an org-wide metric usable in every project. You set this when you create the metric, and can move it later with PATCH.
https://app.avsb.cloud/api/v1/metricsEvery endpoint here follows the shared conventions: the { data } envelope, an Idempotency-Key you can send on every write 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. See rate-limit headers for the response headers that show your remaining budget.
Scopes
Each operation below needs your service token to carry a specific scope (a named permission on the token that controls exactly what it may read or change).
| Operation | Scope |
|---|---|
| List metrics, get one metric | metrics:read |
| Create, update, delete a metric | metrics:write |
| Archive or unarchive a metric | metrics:write |
List metrics
GET /api/v1/metrics: every metric in the org, newest first. Cursor-paginated: pass ?limit= (1 to 100, default 20) and ?cursor=. Requires metrics:read.
curl "https://app.avsb.cloud/api/v1/metrics?limit=20" \ -H "Authorization: Bearer avsb_svc_..."const url = 'https://app.avsb.cloud/api/v1/metrics?limit=20'const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` } })const { data, page } = await res.json()import os, requestsres = requests.get( "https://app.avsb.cloud/api/v1/metrics", params={"limit": 20}, headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data, page = res.json()["data"], res.json()["page"]{ "data": [ { "id": "<metricId>", "shortId": 7, "projectId": "<projectId>", "sourceProjectId": null, "name": "Add to cart", "description": null, "type": "CLICK", "cssSelector": ".add-to-cart", "urlMatch": null, "urlMatchMode": null, "pageUrl": null, "eventKey": null, "hasValueField": false, "defaultDirection": "INCREASE", "archivedAt": null, "createdAt": "2026-06-16T00:00:00.000Z", "updatedAt": "2026-06-16T00:00:00.000Z", "createdByUserId": "<userId>" } ], "page": { "nextCursor": null, "hasMore": false }}A limit outside 1 to 100 is refused with 400 pagination_invalid rather than being silently capped: see Conventions for that error shape.
type is one of CLICK, PAGEVIEW, CUSTOM or PURCHASE. PURCHASE marks the built-in revenue metrics AvsB adds to a project for you; you cannot create one through this API, and it updates like a CUSTOM metric.
Create a metric
POST /api/v1/metrics: create a metric. Requires metrics:write. The body is discriminated by type: CLICK, PAGEVIEW, or CUSTOM.
The projectId key is required, though its value may be null. Send a project id to give the metric a home there, or null to create an org-wide metric. Leaving the key out entirely fails validation on purpose: it forces you to choose a home rather than silently defaulting to one.
curl -X POST https://app.avsb.cloud/api/v1/metrics \ -H "Authorization: Bearer avsb_svc_..." \ -H "Content-Type: application/json" \ -d '{ "type": "CLICK", "name": "Add to cart", "projectId": "<projectId>", "cssSelector": ".add-to-cart" }'const res = await fetch('https://app.avsb.cloud/api/v1/metrics', { method: 'POST', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'CLICK', name: 'Add to cart', projectId: 'cm1a2b3c4d5e6f7g8h9i0j1k2', cssSelector: '.add-to-cart', }),})const { data } = await res.json()import os, requestsres = requests.post( "https://app.avsb.cloud/api/v1/metrics", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"}, json={ "type": "CLICK", "name": "Add to cart", "projectId": "cm1a2b3c4d5e6f7g8h9i0j1k2", "cssSelector": ".add-to-cart", },)data = res.json()["data"]Returns 201 with the created metric, in the same shape shown under List metrics above.
Creating, updating, archiving, or deleting an org-wide metric (projectId: null) needs nothing beyond the metrics:write scope every write on this page already needs. The dashboard's browser session has an extra organization-admin check for org-wide metrics. This public API is reached only with a service token, though, and that extra check is skipped for every token call.
Body shapes by type
CLICK:name,projectId,cssSelector. OptionalpageUrlscopes the click to matching pages.urlMatchModeis required wheneverpageUrlis set (one of the six modes below) and says howpageUrlis compared; a scoped create that leaves it out is refused. Metrics stored before this rule keep matching by substring until they are saved again with a mode.PAGEVIEW:name,projectId,urlMatch, and a requiredurlMatchMode: one ofSIMPLE,EXACT,PATH,SUBSTRING,PATTERN,REGEX.CUSTOM:name,projectId,eventKey(letters, digits,_,-,:only), and a requiredhasValueFieldsaying whether the event carries a number.
Every type also accepts an optional defaultDirection, either "INCREASE" (the default) or "DECREASE", for which way counts as a win. Use "DECREASE" for a metric that is better when it goes down, like bounce rate, load time, or refunds. Each metric binding created for an experiment starts from this default and can override it per experiment. Leave the field out and you get "INCREASE", so existing integrations keep working unchanged.
defaultDirection also matters when you read results back. A result's goalAlignedLiftPct is worked out using this direction, so a positive number always means the metric got better. See Results.
Source project (optional)
Every metric also accepts sourceProjectId, separate from the home projectId. It names the project whose event stream supplies this metric's data. Use it only for the rare case where that project differs from the one the metric lives in or is used in. Leave it out, or send null, and the metric's data comes from whichever project is running the experiment: what almost every metric wants. It appears in every response too, right after projectId.
feature-flag projects (projects built for on/off settings in your code, rather than web experiments) accept Custom metrics only. Sending a Click or Pageview body for a feature-flag project is refused.
{ "error": { "code": "conflict", "message": "A metric with this name already exists in this organization", "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#conflict-errors", "details": { "field": "name", "conflict": { "id": "<metricId>", "name": "Add to cart", "projectId": "<projectId>", "projectShortId": 12, "projectName": "Marketing site" } }, "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77" }}The same shape fires when a CUSTOM metric's eventKey collides, worded for the key instead of the name and with "field": "eventKey". conflict names the existing metric so you can deep-link to it, or edit it instead of retrying the create. A rename through PATCH that lands on a taken name answers the same way.
Get a metric
GET /api/v1/metrics/{metricId}: one metric by id, either its cuid or its short numeric id. A metric outside your org answers 404, the same as one that does not exist at all. Requires metrics:read.
curl https://app.avsb.cloud/api/v1/metrics/<metricId> \ -H "Authorization: Bearer avsb_svc_..."const metricId = 'cm2b3c4d5e6f7g8h9i0j1k2l3'const res = await fetch(`https://app.avsb.cloud/api/v1/metrics/${metricId}`, { headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` },})const { data } = await res.json()import os, requestsmetric_id = "cm2b3c4d5e6f7g8h9i0j1k2l3"res = requests.get( f"https://app.avsb.cloud/api/v1/metrics/{metric_id}", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data = res.json()["data"]Returns 200 with the metric, in the same shape shown under List metrics above.
{ "error": { "code": "not_found", "message": "Metric not found", "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#not-found-errors", "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77" }}Update a metric
PATCH /api/v1/metrics/{metricId}: partial update. Requires metrics:write. A metric's type never changes after create, so only the fields that type accepts are allowed (for a Click metric, that is name, description, cssSelector, pageUrl, or urlMatchMode). A field another type uses is refused with 400. The OpenAPI document and the MCP update_metric tool describe the body as one shape per type: Click, Pageview, and Custom (which Purchase metrics share).
URL matching travels as a pair, so a page filter always says how it is compared:
- On a Click metric, a PATCH carrying a non-null
pageUrlmust carryurlMatchModein the same request. urlMatchMode: nullis accepted only alongsidepageUrl: null, because clearing the mode on its own would leave a still-scoped metric matching by substring. SendingpageUrl: nullby itself is fine: the mode stays parked on the metric and comes back if you add a filter again.- On a Pageview metric, a PATCH carrying
urlMatchmust carryurlMatchModein the same request. SendingurlMatchModeon its own is fine.
curl -X PATCH https://app.avsb.cloud/api/v1/metrics/<metricId> \ -H "Authorization: Bearer avsb_svc_..." \ -H "Content-Type: application/json" \ -d '{ "name": "Add to cart (header)" }'const metricId = 'cm2b3c4d5e6f7g8h9i0j1k2l3'const res = await fetch(`https://app.avsb.cloud/api/v1/metrics/${metricId}`, { method: 'PATCH', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Add to cart (header)' }),})const { data } = await res.json()import os, requestsmetric_id = "cm2b3c4d5e6f7g8h9i0j1k2l3"res = requests.patch( f"https://app.avsb.cloud/api/v1/metrics/{metric_id}", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"}, json={"name": "Add to cart (header)"},)data = res.json()["data"]{ "data": { "id": "<metricId>", "name": "Add to cart (header)", "type": "CLICK", "updatedAt": "2026-06-17T09:00:00.000Z", "...": "the rest of the fields shown under List metrics, unchanged" }}Promote and demote (changing scope)
projectId is a normal PATCH field, so a scope change goes through this same endpoint. There is no separate promote or demote route.
- Leave
projectIdout: nothing about scope changes. - Send
"projectId": null: promote the metric to org-wide. - Send
"projectId": "<otherProjectId>": demote or move the metric to that project.
curl -X PATCH https://app.avsb.cloud/api/v1/metrics/<metricId> \ -H "Authorization: Bearer avsb_svc_..." \ -H "Content-Type: application/json" \ -d '{ "projectId": null }'A demote or move is refused with 409 in one case: the metric is still used by a binding or a flag rule that lives outside the target project. The response lists every blocker so you can detach them first:
{ "error": { "code": "validation_failed", "message": "Cannot move this metric to a project while it is referenced by other projects", "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#validation-errors", "details": { "blockers": [ { "kind": "binding", "id": "<metricBindingId>", "name": "Add to cart rate", "projectId": "<otherProjectId>" }, { "kind": "flag-rule", "id": "<flagRuleMetricId>", "name": "Checkout flag", "projectId": "<otherProjectId>" } ] }, "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77" }}Every blocker is a binding (which may itself be attached to one or more experiments) or a flag-rule. Promoting to org-wide, or editing a metric that is already org-wide, needs nothing beyond metrics:write: see the callout under Create a metric.
Delete a metric
DELETE /api/v1/metrics/{metricId}: permanently deletes the metric. In the same step it detaches the metric from every experiment and feature-flag rule that used it, then republishes the affected project's datafile (the small JSON file the snippet downloads, listing every live experiment, flag, and audience for that project) so the snippet stops looking for the deleted metric. Requires metrics:write.
curl -X DELETE https://app.avsb.cloud/api/v1/metrics/<metricId> \ -H "Authorization: Bearer avsb_svc_..."const metricId = 'cm2b3c4d5e6f7g8h9i0j1k2l3'const res = await fetch(`https://app.avsb.cloud/api/v1/metrics/${metricId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` },})const { data } = await res.json()import os, requestsmetric_id = "cm2b3c4d5e6f7g8h9i0j1k2l3"res = requests.delete( f"https://app.avsb.cloud/api/v1/metrics/{metric_id}", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data = res.json()["data"]{ "data": { "id": "<metricId>", "deleted": true }}Deleting a metric removes it from any running experiment or flag rule using it, immediately. The dashboard shows a usage-aware confirmation before deleting. Over the API there is no such guard, so call GET first if you want to know what a delete would affect.
A missing or cross-org metric answers the same 404 not_found shape shown under Get a metric above. Deleting an org-wide metric follows the same rule as creating one: see the callout under Create a metric.
Archive a metric
POST /api/v1/metrics/{metricId}/archive: the reversible alternative to deleting. Requires metrics:write. The metric stops firing, and is detached from every experiment and flag rule using it, the same way a delete detaches it. The row survives, though, so you can bring it back.
curl -X POST https://app.avsb.cloud/api/v1/metrics/<metricId>/archive \ -H "Authorization: Bearer avsb_svc_..."const metricId = 'cm2b3c4d5e6f7g8h9i0j1k2l3'const res = await fetch(`https://app.avsb.cloud/api/v1/metrics/${metricId}/archive`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` },})const { data } = await res.json()import os, requestsmetric_id = "cm2b3c4d5e6f7g8h9i0j1k2l3"res = requests.post( f"https://app.avsb.cloud/api/v1/metrics/{metric_id}/archive", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data = res.json()["data"]Returns 200 with the metric, now carrying an archivedAt timestamp, in the same shape shown under List metrics above. Safe to retry: archiving an already-archived metric answers 200, not an error, and archivedAt keeps the time it was first archived.
{ "data": { "id": "<metricId>", "archivedAt": "2026-06-18T10:00:00.000Z", "...": "the rest of the fields shown under List metrics, unchanged" }}Archiving an org-wide metric stops it firing in every project in the organization. That still needs nothing beyond metrics:write: see the callout under Create a metric. A missing or cross-org metric answers the same 404 shape shown under Get a metric.
Unarchive a metric
POST /api/v1/metrics/{metricId}/unarchive: clears archivedAt so the metric fires again. Requires metrics:write.
curl -X POST https://app.avsb.cloud/api/v1/metrics/<metricId>/unarchive \ -H "Authorization: Bearer avsb_svc_..."const metricId = 'cm2b3c4d5e6f7g8h9i0j1k2l3'const res = await fetch(`https://app.avsb.cloud/api/v1/metrics/${metricId}/unarchive`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` },})const { data } = await res.json()import os, requestsmetric_id = "cm2b3c4d5e6f7g8h9i0j1k2l3"res = requests.post( f"https://app.avsb.cloud/api/v1/metrics/{metric_id}/unarchive", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data = res.json()["data"]Returns 200 with the metric, archivedAt now null, in the same shape shown under List metrics above.
Unarchiving does not re-attach the metric to the experiments and flag rules the archive detached it from. Restoring a metric must never silently change what a running test measures, so re-attach deliberately with the metric-bindings API.