Public API: Audit & Observability
Every state-changing request authenticated by a service token leaves a trail you can audit, query, and stream. This page covers four signals: the in-app audit log, request-id correlated server logs, the rate-limit headers on every response, and webhooks for state-change subscriptions.
Audit log entries for API actions
Every write that authenticates with a service token produces an audit entry alongside the resource change. The entry records:
actorType: API_TOKEN: distinguishes API actions from dashboard (USER) and system actions.source: API: the channel that triggered the action. Other values:DASHBOARD,CLI,SYSTEM.actorId+actorName: the specific token that authenticated the request, and its display name. Both are copied onto the entry the moment it is written, so the name still reads correctly even if you rename or rotate the token later.metadata.tokenIdrepeats the same token id so you can filter or group on it programmatically.ipAddress+userAgent: the calling client's observable identity.changes: a diff of the resource, one entry per changed field, each holding{ old, new }(where applicable).
Service-token audit entries appear identically to dashboard entries in the UI, but with a token-shaped icon and a footer reading via API · {token name}. No fields are redacted. The full diff is visible to anyone with the audit:read scope or org admin access.
Viewing audit entries in-app
The dashboard ships a dedicated audit-log view at /audit-log. Open it from the organization sidebar, or link directly:
https://app.avsb.cloud/audit-logFilters available in the UI:
- Search: free text, matched against the resource name.
- Action: one action at a time, for example
TOGGLEDorARCHIVED. - Resource type: Project, Experiment, Flag, Audience, etc.
- Source: Dashboard, API, CLI, System.
- Member: narrow to one person's actions. There is no separate picker for a single token; use the API's
actorIdfilter for that (see below). - Date range: pick a start and end day on the calendar. There is no hour-level range and no built-in "last N days" preset.
Reading audit entries over the API
GET /api/v1/audit-logs, behind the audit:read scope. Org-level: the organization comes from the token, so there is no id in the path and a token can only read its own organization's trail.
curl "https://app.avsb.cloud/api/v1/audit-logs?limit=20&resourceType=FLAG" \ -H "Authorization: Bearer avsb_svc_..."const res = await fetch( 'https://app.avsb.cloud/api/v1/audit-logs?limit=20&resourceType=FLAG', { headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` } },)const { data } = await res.json()console.log(data)import os, requestsres = requests.get( "https://app.avsb.cloud/api/v1/audit-logs", params={"limit": 20, "resourceType": "FLAG"}, headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data = res.json()["data"]{ "data": [ { "id": "<auditEntryId>", "orgId": "<orgId>", "actorId": "<tokenId>", "actorName": "Terraform CI", "actorEmail": null, "actorType": "API_TOKEN", "action": "TOGGLED", "status": "SUCCESS", "failureReason": null, "resourceType": "FLAG", "resourceId": "<flagId>", "resourceName": "New checkout", "projectId": "<projectId>", "experimentId": null, "source": "API", "ipAddress": "203.0.113.42", "userAgent": "terraform-provider-avsb/0.4.1", "correlationId": null, "changes": { "enabled": { "old": true, "new": false } }, "metadata": { "tokenId": "<tokenId>" }, "createdAt": "2026-07-30T12:00:00.000Z" } ], "page": { "nextCursor": null, "hasMore": false }}Filters
| Parameter | Notes |
|---|---|
limit | 1 to 100, default 20. |
cursor | The opaque page.nextCursor from the previous response. |
action | One or more action names, comma-separated (for example TOGGLED,ARCHIVED). |
resourceType | One or more resource types, comma-separated (for example FLAG,EXPERIMENT). |
actorId | A user id or a token id: everything one actor did. |
source | DASHBOARD, API, CLI, or SYSTEM. |
status | SUCCESS or FAILED. Failed entries record refused attempts. |
projectId | Narrow to one project. |
correlationId | Group the entries written by one operation. |
from, to | ISO-8601 instants. Either or both. |
A filter value that is not a real action or resource type returns 400 validation_failed. That is deliberate: silently dropping an unrecognised filter would widen an audit read, which is the worst way for this endpoint to fail.
actorType is USER, API_TOKEN, or SYSTEM, and the API always reports the real one. Telling a token's writes apart from a person's is the reason to read this over the API rather than eyeballing the dashboard.
The audit log is append-only: entries are written as a side effect of the action being audited, so there is no write endpoint. The same entries are browsable in the dashboard's Audit Log page (the sidebar link described above).
Server-side logging and request correlation
Every response from the public API carries an X-Request-Id header: an opaque id unique to that request. When a call fails, especially with a 500, the failure is logged on our side with that same id, the HTTP method, and the path.
Quote the X-Request-Id value when you contact support and we can find the exact log line for your request, without you needing to share the request or response body. Neither is ever logged, and token secrets never are either. If you need the diff of a write, query the audit log above: it is the trail the public API actually hands you, not the server log.
Rate-limit headers as observability signal
Every response carries the standard rate-limit headers: graph them in your observability stack to spot capacity issues before they turn into 429 responses:
X-RateLimit-Limit: current quota (requests/minute) for this token: 600 for reads, 120 for writes.X-RateLimit-Remaining: alarm onremaining/limit < 0.1.X-RateLimit-Reset: unix timestamp; use to drive token-bucket back-off.Retry-After: present only on429responses; respect it before retrying.
// Your own metrics client.declare const metrics: { gauge(name: string, value: number): void }// Forward rate-limit headers into your metrics pipelinefunction recordRateLimit(res: Response): void { metrics.gauge('avsb_api.rate_limit.remaining', Number(res.headers.get('X-RateLimit-Remaining'))) metrics.gauge('avsb_api.rate_limit.limit', Number(res.headers.get('X-RateLimit-Limit')))}Webhooks for state changes
For push-based observability (i.e. you want to be told when a resource changes rather than polling the audit log), subscribe to webhooks. On the public API, webhooks are configured per-project and cover experiment events, flag events, exclusion-group changes, and commerce/background-work events for that project.
There is no dedicated event for token activity or for the audit log itself: subscribe to the experiment and flag events that matter to your integration instead, and keep reading the audit log above for a full, queryable trail of who changed what. The event types most useful for observability:
- Experiment lifecycle:
experiment.launched,.paused,.resumed,.completed,.changes_published. - Experiment safety and results:
experiment.srm_failed,.guardrail_breached,.guardrail_metric_breached,.code_error_detected,.winner_declared,.results_ready. - Flags:
flag.published,flag.rule_activated,flag.srm_failed,flag.guardrail_breached,flag.results_ready.
curl https://app.avsb.cloud/api/v1/projects/<projectId>/webhooks \ -X POST \ -H "Authorization: Bearer avsb_svc_..." \ -H "Content-Type: application/json" \ -d '{ "name": "SRM and guardrail alerts", "url": "https://hooks.your-host/avsb", "events": ["experiment.srm_failed", "experiment.guardrail_breached"] }'const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const res = await fetch( `https://app.avsb.cloud/api/v1/projects/${projectId}/webhooks`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'SRM and guardrail alerts', url: 'https://hooks.your-host/avsb', events: ['experiment.srm_failed', 'experiment.guardrail_breached'], }), },)const { data } = await res.json()console.log(data.secret)import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"res = requests.post( f"https://app.avsb.cloud/api/v1/projects/{project_id}/webhooks", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"}, json={ "name": "SRM and guardrail alerts", "url": "https://hooks.your-host/avsb", "events": ["experiment.srm_failed", "experiment.guardrail_breached"], },)data = res.json()["data"]secret = data["secret"]AvsB generates the signing secret; you never choose one. It rides in the create response's secret field, once. Store it immediately: no read endpoint returns it again, and the only way to get a new one is to rotate, which invalidates the old.
Every webhook delivery is signed with an HMAC-SHA256 over the delivery id, the timestamp, and the body, keyed by that signing secret, and passed as the X-AvsB-Signature header. Reject deliveries whose signature does not verify, and reject deliveries older than five minutes. verifyWebhookSignature in @avsbhq/node does both; see Verifying signatures for the exact scheme.
Webhook delivery is at-least-once with exponential back-off, so always de-duplicate by the delivery id header. See the Webhooks guide for the full signing flow, retry schedule, and replay UI.