Public API: Metric bindings
A metric binding is a saved, reusable definition of what you want to measure. It applies a measure, such as "unique conversions per visitor" or a profit-style "total value per visitor", over one or more tracked metrics. Experiments attach bindings as their primary, secondary, or guardrail metrics (metrics you are not trying to improve but do not want to make worse, like page load time). A binding is the unit you create once and reuse across many tests.
Metric bindings are organization-scoped on the public API. Your token already names its organization, so the path carries no {orgId}, and bindings are listed across the whole org. Every endpoint here authenticates with a Bearer token: a service token or a personal access token. Like metrics, a binding has an optional home project: projectId is a string (the home project) or null (an org-wide binding usable in every project). You choose org-wide or project-level on create, and change it later with PATCH.
https://app.avsb.cloud/api/v1/metric-bindingsEvery endpoint here follows the shared conventions: the { data } envelope, an Idempotency-Key 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. Every endpoint here also needs a plan that includes Integrations and API access; without it, every call answers 403 feature_disabled. See rate-limit headers and Refusals at 403.
This API serves the modern binding shape: a measure over tracked metrics, with joined metric details so you can render the binding sentence without a follow-up call. It does not return the older legacy metric-type reshaping.
The AvsB dashboard requires an organization admin to create, edit, or delete an org-wide binding. This API does not add that check: any token carrying metrics:write can create, promote, demote, or delete an org-wide binding. Grant that permission only to tokens you trust with it.
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 bindings | metrics:read |
| Create / update / delete a binding | metrics:write |
The binding shape
Every endpoint that returns a single binding wraps it in { "data": <binding> }. The list endpoint returns the same full shape for each item, under { "data": [<binding>], "page": { ... } }.
{ "data": { "id": "<metricBindingId>", "shortId": 7, "orgId": "<orgId>", "projectId": "<projectId>", "name": "Add to cart rate", "description": null, "measure": "UNIQUE_CONVERSIONS_PER_VISITOR", "direction": "INCREASE", "isLibrary": true, "metricId": "<metricId>", "numeratorMetricId": null, "denominatorMetricId": null, "percentile": null, "valueSource": null, "winsorization": null, "composition": null, "archivedAt": null, "createdAt": "2026-06-18T00:00:00.000Z", "updatedAt": "2026-06-18T00:00:00.000Z", "createdByUserId": "<userId>", "metric": { "id": "<metricId>", "shortId": 3, "projectId": "<projectId>", "name": "Add to cart click", "description": null, "type": "CLICK", "cssSelector": ".add-to-cart", "urlMatch": null, "urlMatchMode": null, "pageUrl": null, "eventKey": null, "hasValueField": false, "archivedAt": null }, "numeratorMetric": null, "denominatorMetric": null }}Field notes
measureis one ofUNIQUE_CONVERSIONS_PER_VISITOR,TOTAL_EVENTS,UNIQUE_VISITORS_WHO_FIRED,TOTAL_VALUE_PER_VISITOR,TOTAL_VALUE,PERCENTILE,RATE, orCOMPOSITE. The measure decides which metric fields are required (see below).metric/numeratorMetric/denominatorMetricare the joined tracked-metric rows. Single-metric measures populatemetric; aRATEpopulates the numerator/denominator pair; aCOMPOSITEreferences its components throughcompositioninstead, and leaves all three joined fieldsnull.percentileapplies to thePERCENTILEmeasure (a number greater than 0 and less than 100, ornull).winsorization, when set, is{ "enabled": boolean, "upperPercentile": number, "lowerPercentile": number, "scope": "POOLED" | "PER_ARM" }. Defaults are99,0, and"POOLED";lowerPercentilemust be strictly less thanupperPercentile.composition, for aCOMPOSITEbinding, is a list of{ "metricId": string, "weight": number }entries: 2 to 10 of them, weights summing to a positive number, no repeatedmetricId.archivedAtreflects whether the binding is archived elsewhere in AvsB. This API has no endpoint that sets it:PATCHdoes not accept anarchivedAtfield, andDELETEremoves the row outright rather than archiving it.
List metric bindings
GET /api/v1/metric-bindings: every binding in the organization, newest first. Cursor-paginated.
curl https://app.avsb.cloud/api/v1/metric-bindings \ -H "Authorization: Bearer avsb_svc_..."const res = await fetch('https://app.avsb.cloud/api/v1/metric-bindings?limit=20', { 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/metric-bindings", params={"limit": 20}, headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data, page = res.json()["data"], res.json()["page"]Pass ?limit= (1 to 100, default 20) and the opaque ?cursor= from the previous page's page.nextCursor to walk through results. Each item in data is the full binding shown under The binding shape above, joined metric and all, not a trimmed summary.
{ "data": [ { "id": "<metricBindingId>", "shortId": 7, "orgId": "<orgId>", "projectId": "<projectId>", "name": "Add to cart rate", "description": null, "measure": "UNIQUE_CONVERSIONS_PER_VISITOR", "direction": "INCREASE", "isLibrary": true, "metricId": "<metricId>", "numeratorMetricId": null, "denominatorMetricId": null, "percentile": null, "valueSource": null, "winsorization": null, "composition": null, "archivedAt": null, "createdAt": "2026-06-18T00:00:00.000Z", "updatedAt": "2026-06-18T00:00:00.000Z", "createdByUserId": "<userId>", "metric": { "id": "<metricId>", "shortId": 3, "projectId": "<projectId>", "name": "Add to cart click", "description": null, "type": "CLICK", "cssSelector": ".add-to-cart", "urlMatch": null, "urlMatchMode": null, "pageUrl": null, "eventKey": null, "hasValueField": false, "archivedAt": null }, "numeratorMetric": null, "denominatorMetric": null } ], "page": { "nextCursor": null, "hasMore": false }}Create a metric binding
POST /api/v1/metric-bindings: create a binding. The projectId key is required, but its value may be null. Pass a project id to home the binding there, or null for an org-wide binding usable across every project. Omitting the key entirely is a validation error: the scope choice is always explicit.
curl -X POST https://app.avsb.cloud/api/v1/metric-bindings \ -H "Authorization: Bearer avsb_svc_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "measure": "UNIQUE_CONVERSIONS_PER_VISITOR", "name": "Add to cart rate", "projectId": "<projectId>", "metricId": "<metricId>", "direction": "INCREASE" }'const res = await fetch('https://app.avsb.cloud/api/v1/metric-bindings', { method: 'POST', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify({ measure: 'UNIQUE_CONVERSIONS_PER_VISITOR', name: 'Add to cart rate', projectId: '<projectId>', metricId: '<metricId>', direction: 'INCREASE', }),})const { data } = await res.json()import os, uuid, requestsres = requests.post( "https://app.avsb.cloud/api/v1/metric-bindings", headers={ "Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}", "Idempotency-Key": str(uuid.uuid4()), }, json={ "measure": "UNIQUE_CONVERSIONS_PER_VISITOR", "name": "Add to cart rate", "projectId": "<projectId>", "metricId": "<metricId>", "direction": "INCREASE", },)data = res.json()["data"]The required fields depend on measure:
- Single-metric (
UNIQUE_CONVERSIONS_PER_VISITOR,TOTAL_EVENTS,UNIQUE_VISITORS_WHO_FIRED,TOTAL_VALUE_PER_VISITOR,TOTAL_VALUE,PERCENTILE): requiremetricId. Value measures also acceptvalueSource(defaults to"value");PERCENTILEalso requirespercentile. RATE: requiresnumeratorMetricIdanddenominatorMetricId.COMPOSITE: requirescomposition, a list of 2 to 10{ "metricId": ..., "weight": ... }entries whose weights sum to a positive number.
direction defaults to "INCREASE" and isLibrary defaults to true. Returns 201 with { "data": <binding> }. This endpoint is idempotent: replay the same request with the same Idempotency-Key and it changes nothing the second time.
{ "error": { "code": "validation_failed", "message": "composite must have at least 2 components", "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#validation-errors", "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77" }}Get a metric binding
GET /api/v1/metric-bindings/{metricBindingId}
curl https://app.avsb.cloud/api/v1/metric-bindings/<metricBindingId> \ -H "Authorization: Bearer avsb_svc_..."const metricBindingId = '<metricBindingId>'const res = await fetch(`https://app.avsb.cloud/api/v1/metric-bindings/${metricBindingId}`, { headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` },})const { data } = await res.json()import os, requestsmetric_binding_id = "<metricBindingId>"res = requests.get( f"https://app.avsb.cloud/api/v1/metric-bindings/{metric_binding_id}", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data = res.json()["data"]Returns the same shape as The binding shape above.
{ "error": { "code": "not_found", "message": "Metric binding not found", "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#not-found-errors", "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77" }}A binding in another org resolves the same way: 404, so a cross-org id never reveals whether it exists.
Update a metric binding
PATCH /api/v1/metric-bindings/{metricBindingId}: change any field. Send only the fields you want to change.
curl -X PATCH https://app.avsb.cloud/api/v1/metric-bindings/<metricBindingId> \ -H "Authorization: Bearer avsb_svc_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "name": "Add to cart rate (mobile)" }'const metricBindingId = '<metricBindingId>'const res = await fetch(`https://app.avsb.cloud/api/v1/metric-bindings/${metricBindingId}`, { method: 'PATCH', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify({ name: 'Add to cart rate (mobile)' }),})const { data } = await res.json()import os, uuid, requestsmetric_binding_id = "<metricBindingId>"res = requests.patch( f"https://app.avsb.cloud/api/v1/metric-bindings/{metric_binding_id}", headers={ "Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}", "Idempotency-Key": str(uuid.uuid4()), }, json={"name": "Add to cart rate (mobile)"},)data = res.json()["data"]Returns { "data": <binding> }. Changing a measure or its metric references re-runs the same validation as create, for example the RATE and COMPOSITE checks above. This endpoint is idempotent.
Promote and demote (changing scope)
projectId is an ordinary PATCH field, so scope changes reuse this endpoint: there are no separate promote/demote routes. Send "projectId": null to promote the binding to org-wide, or a different project id to demote or move it.
A demote (or move) is rejected with 409 when the binding is still referenced from other projects: experiments that attach it, or composite bindings that name it as a component, outside the target project. The response lists every blocker:
{ "error": { "code": "validation_failed", "message": "Cannot move this metric binding to a project while it is referenced by other projects", "details": { "blockers": [ { "kind": "experiment", "id": "<experimentId>", "name": "Homepage hero", "projectId": "<otherProjectId>" } ] }, "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#conflict-errors", "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77" }}Delete a metric binding
DELETE /api/v1/metric-bindings/{metricBindingId}
curl -X DELETE https://app.avsb.cloud/api/v1/metric-bindings/<metricBindingId> \ -H "Authorization: Bearer avsb_svc_..." \ -H "Idempotency-Key: $(uuidgen)"const metricBindingId = '<metricBindingId>'const res = await fetch(`https://app.avsb.cloud/api/v1/metric-bindings/${metricBindingId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}`, 'Idempotency-Key': crypto.randomUUID(), },})const { data } = await res.json()import os, uuid, requestsmetric_binding_id = "<metricBindingId>"res = requests.delete( f"https://app.avsb.cloud/api/v1/metric-bindings/{metric_binding_id}", headers={ "Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}", "Idempotency-Key": str(uuid.uuid4()), },)data = res.json()["data"]{ "data": { "id": "<metricBindingId>", "deleted": true } }A binding that is still attached to an experiment cannot be deleted:
{ "error": { "code": "validation_failed", "message": "Cannot delete: this metric is still attached to experiments", "details": { "experimentsReferencing": [{ "experimentId": "<experimentId>", "experimentName": "Homepage hero" }] }, "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#validation-errors", "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77" }}Moving or demoting a binding blocks the request when another composite binding still names it as a component (the 409 above). Deleting does not run that same check: only experiment attachments block a delete. Deleting a binding that another composite binding still uses as a component leaves that composite with a dangling reference. Check for composite users yourself before deleting a binding you expect others to reuse.