Public API: Webhooks
A webhook is an automatic HTTP request A vs B sends to your own server when something happens, like an experiment going live. This API lets a service token manage a project's webhooks. Use it to create, update, or delete a webhook, rotate its signing secret, send a test ping, and inspect or retry past deliveries.
All webhook endpoints live under a project and authenticate with a service token. The org comes from the token, so the path only carries {projectId} (and {webhookId} / {deliveryId} where relevant), not {orgId}.
https://app.avsb.cloud/api/v1/projects/{projectId}/webhooksThis page manages the webhooks themselves. For the outbound payload format, the signature scheme, and the retry ladder your endpoint has to handle, see the Webhooks guide.
The signing secret is returned only when you create a webhook or rotate its secret. List and get responses never include it. A read-only token must never be able to collect signing material for every endpoint in a project. So the secret is shown once, at the moment you can store it. If you lose it, rotate. That includes a retry with the same Idempotency-Key: the replayed response comes back with secret: null, because the replay store never keeps a secret.
Four things apply to every endpoint on this page, not just one of them:
- Plan requirement. Every operation below needs your organization's plan to include the
integrations_api_exportfeature. Without it, every call returns403with error codefeature_disabled. See Refusals at 403. - Rate limits. Reads (
GET) are capped at 600 requests a minute per token; writes (POST,PATCH,DELETE) are capped at 120 a minute. Every response carries theX-RateLimit-*headers so you can check your budget before you hit it. See Rate-limit headers. - Retry-safe writes. Every write here accepts an optional
Idempotency-Keyheader: create, update, delete, rotate the secret, send a test ping, retry a delivery. Retrying after a dropped connection can never create a second webhook or double-rotate a secret. See Idempotency-Key. - Conditional writes. A webhook
GETreturns anETagheader. Send it back asX-Avsb-If-Matchon thePATCHand a webhook that changed since you read it is refused with412 precondition_failed, with the currentETagindetails.expected. Without the header the write is unconditional. See ETag / If-Match.
Scopes
A scope is a named permission on your token. It decides exactly what that token can read or change.
| Operation | Scope |
|---|---|
| List / get webhooks, list deliveries | webhooks:read |
| Create / update / delete, rotate secret, test, retry | webhooks:write |
List webhooks
GET /api/v1/projects/{projectId}/webhooks: newest-created first, cursor-paginated. limit and cursor are the only query parameters it accepts.
curl "https://app.avsb.cloud/api/v1/projects/<projectId>/webhooks?limit=20" \ -H "Authorization: Bearer avsb_svc_..."const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/webhooks?limit=20`const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` } })const { data, page } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"res = requests.get( f"https://app.avsb.cloud/api/v1/projects/{project_id}/webhooks", params={"limit": 20}, headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data, page = res.json()["data"], res.json()["page"]{ "data": [ { "id": "<webhookId>", "projectId": "<projectId>", "orgId": "<orgId>", "name": "Slack alerts", "url": "https://example.com/hook", "events": ["experiment.launched", "experiment.completed"], "enabled": true, "disabledAt": null, "disableReason": null, "consecutiveFailures": 0, "createdAt": "2026-06-18T00:00:00.000Z", "updatedAt": "2026-06-18T00:00:00.000Z" } ], "page": { "nextCursor": null, "hasMore": false }}No secret field: reads never carry signing material. Pass ?cursor=<page.nextCursor> to fetch the next page. limit defaults to 20, max 100. A value outside that range is refused, not clamped down to the max: see the cursor pagination error shape if you build the query string yourself.
Get a webhook
GET /api/v1/projects/{projectId}/webhooks/{webhookId}
curl https://app.avsb.cloud/api/v1/projects/<projectId>/webhooks/<webhookId> \ -H "Authorization: Bearer avsb_svc_..."const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const webhookId = 'clx3c4d5e6f7g8h9i0j1k2l3'const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/webhooks/${webhookId}`const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` } })const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"webhook_id = "clx3c4d5e6f7g8h9i0j1k2l3"res = requests.get( f"https://app.avsb.cloud/api/v1/projects/{project_id}/webhooks/{webhook_id}", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data = res.json()["data"]Returns the single webhook in a flat { data: <webhook> } envelope, the same shape as one item in the list response above, minus the signing secret.
A webhook id from another org, or from a Slack, Teams, or Jira destination, resolves the same as a missing one:
{ "error": { "code": "not_found", "message": "Webhook not found", "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#not-found-errors", "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77" }}Existence is hidden on purpose: a token from another org gets the same 404 a made-up id would.
Create a webhook
POST /api/v1/projects/{projectId}/webhooks: name, url, and events are all required. There are no optional fields on this call.
curl -X POST https://app.avsb.cloud/api/v1/projects/<projectId>/webhooks \ -H "Authorization: Bearer avsb_svc_..." \ -H "Content-Type: application/json" \ -d '{ "name": "Slack alerts", "url": "https://example.com/hook", "events": ["experiment.launched"] }'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: 'Slack alerts', url: 'https://example.com/hook', events: ['experiment.launched'], }),})const { data } = await res.json()// data.secret only appears in this response. Store it before you do anything else.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": "Slack alerts", "url": "https://example.com/hook", "events": ["experiment.launched"], },)data = res.json()["data"] # data["secret"] only appears in this response{ "data": { "id": "<webhookId>", "projectId": "<projectId>", "orgId": "<orgId>", "name": "Slack alerts", "url": "https://example.com/hook", "events": ["experiment.launched"], "enabled": true, "disabledAt": null, "disableReason": null, "consecutiveFailures": 0, "createdAt": "2026-06-18T00:00:00.000Z", "updatedAt": "2026-06-18T00:00:00.000Z", "secret": "fb8abcbbaa719efc242eeaa8e7e6b8e1d1d991affcdf78ca1f55af5cb0adf602" }}Responds 201. secret is a 64-character random hex string; it does not carry a prefix like whsec_. Only two responses ever return it: this one, and Rotate the signing secret below.
A real subscription usually spans more than one event family:
curl -X POST https://app.avsb.cloud/api/v1/projects/<projectId>/webhooks \ -H "Authorization: Bearer avsb_svc_..." \ -H "Content-Type: application/json" \ -d '{ "name": "Experiment + flag alerts", "url": "https://example.com/hook", "events": [ "experiment.launched", "experiment.completed", "experiment.srm_failed", "flag.rule_activated" ] }'experiment.srm_failed is a safety event. It ignores your organization's keyword-based notification filter, so it still arrives even when your other webhooks only fire for experiments matching one keyword.
A destination A vs B cannot safely reach is refused before anything is saved:
{ "error": { "code": "validation_failed", "message": "URL resolves to a private IP address (10.0.0.5)", "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#validation-errors", "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77" }}The same check also rejects plain HTTP, localhost, a non-default port, and a hostname A vs B cannot resolve. A literal IP address in the URL is refused before any lookup, with the message "URL must use a hostname, not an IP address". It also rejects a URL with a username or password embedded in it. An organization can hold up to 100 webhooks in total, counted across every project. Going over that returns the same 400 shape, with the message "Maximum number of webhooks exceeded". Requires webhooks:write.
Update a webhook
PATCH /api/v1/projects/{projectId}/webhooks/{webhookId}: every field is optional. Send only what changes.
curl -X PATCH https://app.avsb.cloud/api/v1/projects/<projectId>/webhooks/<webhookId> \ -H "Authorization: Bearer avsb_svc_..." \ -H "Content-Type: application/json" \ -d '{ "enabled": false }'const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const webhookId = 'clx3c4d5e6f7g8h9i0j1k2l3'const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/webhooks/${webhookId}`const res = await fetch(url, { method: 'PATCH', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: false }),})const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"webhook_id = "clx3c4d5e6f7g8h9i0j1k2l3"res = requests.patch( f"https://app.avsb.cloud/api/v1/projects/{project_id}/webhooks/{webhook_id}", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"}, json={"enabled": False},)data = res.json()["data"]{ "data": { "id": "<webhookId>", "projectId": "<projectId>", "orgId": "<orgId>", "name": "Slack alerts", "url": "https://example.com/hook", "events": ["experiment.launched"], "enabled": false, "disabledAt": "2026-06-19T08:30:00.000Z", "disableReason": null, "consecutiveFailures": 0, "createdAt": "2026-06-18T00:00:00.000Z", "updatedAt": "2026-06-19T08:30:00.000Z" }}Turning enabled off stops deliveries right away and stamps disabledAt with the current time. Turning it back on clears disabledAt and disableReason and resets consecutiveFailures to 0.
enabled can travel with name, url, or events in the same body. The content fields are applied first and the toggle second, so the response shows the state after both. A rejected URL refuses the request whole: no field changes, and the webhook stays on or off exactly as it was.
To change the name, URL, or event list instead:
curl -X PATCH https://app.avsb.cloud/api/v1/projects/<projectId>/webhooks/<webhookId> \ -H "Authorization: Bearer avsb_svc_..." \ -H "Content-Type: application/json" \ -d '{ "name": "Experiment + flag alerts", "events": ["experiment.launched", "experiment.completed", "flag.rule_activated"] }'Sending events replaces the whole subscription list. There is no way to add or remove a single event type without resending the full array. A webhook id from another org, or one that never existed, returns the same 404 not_found shown under Get a webhook above. Requires webhooks:write.
Delete a webhook
DELETE /api/v1/projects/{projectId}/webhooks/{webhookId}: removes the webhook and its stored delivery history together, and returns { "data": null } at 200.
curl -X DELETE https://app.avsb.cloud/api/v1/projects/<projectId>/webhooks/<webhookId> \ -H "Authorization: Bearer avsb_svc_..."const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const webhookId = 'clx3c4d5e6f7g8h9i0j1k2l3'const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/webhooks/${webhookId}`const res = await fetch(url, { method: 'DELETE', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` },})const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"webhook_id = "clx3c4d5e6f7g8h9i0j1k2l3"res = requests.delete( f"https://app.avsb.cloud/api/v1/projects/{project_id}/webhooks/{webhook_id}", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data = res.json()["data"]{ "data": null }A webhook id from another org, or one that never existed, returns the same 404 not_found shown under Get a webhook above.
Rotate the signing secret
POST /api/v1/projects/{projectId}/webhooks/{webhookId}/secret: generates a new signing secret and returns it at 200.
curl -X POST https://app.avsb.cloud/api/v1/projects/<projectId>/webhooks/<webhookId>/secret \ -H "Authorization: Bearer avsb_svc_..."const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const webhookId = 'clx3c4d5e6f7g8h9i0j1k2l3'const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/webhooks/${webhookId}/secret`const res = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` },})const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"webhook_id = "clx3c4d5e6f7g8h9i0j1k2l3"res = requests.post( f"https://app.avsb.cloud/api/v1/projects/{project_id}/webhooks/{webhook_id}/secret", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data = res.json()["data"]{ "data": { "secret": "3b275945d634bb4aa099a4c52d644efa282bd25ef462e6bacbe55b6028c9eac7" } }The old secret stops working immediately. There is no overlap window, so update your endpoint's verification code before you rotate, not after. A webhook id from another org, or one that never existed, returns the same 404 not_found shown under Get a webhook above.
Send a test ping
POST /api/v1/projects/{projectId}/webhooks/{webhookId}/test: delivers a signed webhook.test payload straight to your endpoint and returns the resulting delivery record at 200.
curl -X POST https://app.avsb.cloud/api/v1/projects/<projectId>/webhooks/<webhookId>/test \ -H "Authorization: Bearer avsb_svc_..."const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const webhookId = 'clx3c4d5e6f7g8h9i0j1k2l3'const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/webhooks/${webhookId}/test`const res = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` },})const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"webhook_id = "clx3c4d5e6f7g8h9i0j1k2l3"res = requests.post( f"https://app.avsb.cloud/api/v1/projects/{project_id}/webhooks/{webhook_id}/test", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data = res.json()["data"]{ "data": { "id": "<deliveryRowId>", "destinationId": "<webhookId>", "deliveryId": "clz9k2m4n6p8q0r2s4t6u8v0w", "eventType": "webhook.test", "sourceEntityId": null, "payload": { "id": "clz9k2m4n6p8q0r2s4t6u8v0w", "event": "webhook.test", "timestamp": "2026-06-18T00:00:05.000Z", "project": { "id": "<projectId>" }, "test": true, "message": "This is a test ping from A vs B" }, "status": "DELIVERED", "attempts": 1, "lastAttemptAt": "2026-06-18T00:00:05.000Z", "nextRetryAt": null, "responseStatus": 200, "responseBody": "OK", "errorMessage": null, "createdAt": "2026-06-18T00:00:00.000Z" }}This call itself always returns 200, even when your endpoint rejects the ping or times out. Read the status field to know what really happened: DELIVERED means your endpoint answered with a 2xx status, FAILED means it did not.
A test ping calls your endpoint directly and skips event routing entirely. So a DELIVERED result only proves A vs B can reach your endpoint. It does not prove that a real event you listed in events will actually arrive. Real deliveries also depend on your organization's notification settings being switched on, and the test bypasses that check. A webhook id from another org, or one that never existed, returns the same 404 not_found shown under Get a webhook above.
List delivery attempts
GET /api/v1/projects/{projectId}/webhooks/{webhookId}/deliveries: recent delivery attempts from the last 30 days, newest first, cursor-paginated the same way as List webhooks above.
curl "https://app.avsb.cloud/api/v1/projects/<projectId>/webhooks/<webhookId>/deliveries?limit=20" \ -H "Authorization: Bearer avsb_svc_..."const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const webhookId = 'clx3c4d5e6f7g8h9i0j1k2l3'const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/webhooks/${webhookId}/deliveries?limit=20`const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` } })const { data, page } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"webhook_id = "clx3c4d5e6f7g8h9i0j1k2l3"res = requests.get( f"https://app.avsb.cloud/api/v1/projects/{project_id}/webhooks/{webhook_id}/deliveries", params={"limit": 20}, headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data, page = res.json()["data"], res.json()["page"]{ "data": [ { "id": "<deliveryRowId>", "destinationId": "<webhookId>", "deliveryId": "clz9k2m4n6p8q0r2s4t6u8v0w", "eventType": "experiment.launched", "sourceEntityId": "<experimentId>", "status": "FAILED", "attempts": 2, "lastAttemptAt": "2026-06-18T00:05:00.000Z", "nextRetryAt": "2026-06-18T00:35:00.000Z", "responseStatus": 503, "responseBody": "Service Unavailable", "errorMessage": "Endpoint returned 503", "createdAt": "2026-06-18T00:00:00.000Z" } ], "page": { "nextCursor": null, "hasMore": false }}Each row is the stored delivery record itself. It can carry a few more fields than shown above as A vs B's notification system grows, so treat any field you don't recognise as informational. Deliveries older than 30 days stop appearing here even though they did happen.
Retry a delivery
POST /api/v1/projects/{projectId}/webhooks/{webhookId}/deliveries/{deliveryId}/retry: re-queues a failed delivery for another attempt at 200. The {deliveryId} in the path is the delivery record's id from the delivery log, not its deliveryId field (the id your endpoint received in X-AvsB-Delivery-Id): a deliveryId value there answers 404.
curl -X POST https://app.avsb.cloud/api/v1/projects/<projectId>/webhooks/<webhookId>/deliveries/<deliveryId>/retry \ -H "Authorization: Bearer avsb_svc_..."const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const webhookId = 'clx3c4d5e6f7g8h9i0j1k2l3'const deliveryId = '<deliveryRowId>' // the delivery record's `id`, not its `deliveryId` fieldconst url = `https://app.avsb.cloud/api/v1/projects/${projectId}/webhooks/${webhookId}/deliveries/${deliveryId}/retry`const res = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` },})const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"webhook_id = "clx3c4d5e6f7g8h9i0j1k2l3"delivery_id = "<deliveryRowId>" # the delivery record's `id`, not its `deliveryId` fieldres = requests.post( f"https://app.avsb.cloud/api/v1/projects/{project_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/retry", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data = res.json()["data"]{ "data": { "id": "<deliveryRowId>", "destinationId": "<webhookId>", "deliveryId": "clz9k2m4n6p8q0r2s4t6u8v0w", "eventType": "experiment.launched", "status": "PENDING", "attempts": 0, "nextRetryAt": "2026-06-18T00:40:00.000Z", "errorMessage": null, "createdAt": "2026-06-18T00:00:00.000Z" }}Only a delivery whose status is FAILED, or one that used up every retry and moved to a permanent failure state, can be retried. Retrying anything else, including one that already shows DELIVERED or is still PENDING, is refused:
{ "error": { "code": "validation_failed", "message": "Only failed deliveries can be retried", "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#validation-errors", "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77" }}A delivery id that does not belong to this webhook returns the same 404 not_found shown under Get a webhook above.