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}.

Plain text
https://app.avsb.cloud/api/v1/projects/{projectId}/webhooks
Plain text1 line
Info

This 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.

Warning

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_export feature. Without it, every call returns 403 with error code feature_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 the X-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-Key header: 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 GET returns an ETag header. Send it back as X-Avsb-If-Match on the PATCH and a webhook that changed since you read it is refused with 412 precondition_failed, with the current ETag in details.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.

OperationScope
List / get webhooks, list deliverieswebhooks:read
Create / update / delete, rotate secret, test, retrywebhooks: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_..."
Shell2 lines
Response
{  "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 }}
JSON19 lines

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_..."
Shell2 lines

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:

404: webhook not found
{  "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"  }}
JSON8 lines

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"]  }'
Shell8 lines
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"  }}
JSON17 lines

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: subscribing to lifecycle, a safety alert, and a flag event
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"    ]  }'
Shell13 lines

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:

400: destination resolves to a private IP address
{  "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"  }}
JSON8 lines

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 }'
Shell4 lines
Response
{  "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"  }}
JSON16 lines

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.

One request can edit and toggle

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: renaming and re-subscribing
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"]  }'
Shell7 lines

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_..."
Shell2 lines
Response
{ "data": null }
JSON1 line

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_..."
Shell2 lines
Response
{ "data": { "secret": "3b275945d634bb4aa099a4c52d644efa282bd25ef462e6bacbe55b6028c9eac7" } }
JSON1 line

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_..."
Shell2 lines
Response
{  "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"  }}
JSON25 lines
A 200 does not mean your endpoint answered

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_..."
Shell2 lines
Response
{  "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 }}
JSON20 lines

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_..."
Shell2 lines
Response
{  "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"  }}
JSON13 lines

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:

400: delivery is not retryable
{  "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"  }}
JSON8 lines

A delivery id that does not belong to this webhook returns the same 404 not_found shown under Get a webhook above.

Next steps

Was this helpful?