Webhooks

A webhook is an automatic HTTP request A vs B sends to your own server the moment something happens, like an experiment going live. Use webhooks to integrate A vs B with Slack, CI/CD pipelines, data warehouses, monitoring tools, or any system that can receive HTTP requests.

Setting up a webhook

  1. Go to Organization Settings, then Integrations. Scroll to the Webhooks section.
  2. Click Create Webhook.
  3. Enter a name (e.g. "Slack Notifications") and the HTTPS URL of your endpoint.
  4. Choose a project, then tick the events that should fire on this webhook.
  5. Click Create Webhook. Copy the signing secret shown: you will need it to verify webhook signatures.
  6. Click Send Test Ping from the webhook detail view to verify your endpoint is receiving requests.
Creating a webhook takes a name, an HTTPS URL, a project, and the events that fire on it.
  1. The name is yours alone, and the URL is the endpoint that will receive the request.
  2. A webhook belongs to the organization, but which events reach it is decided per project. The project field is that choice, and the events you tick apply to it.

To send the same webhook a different set of events from a second project, open the webhook, pick that project, and tick its events. You can also do it from the project's own Settings, then Notifications.

HTTPS required

Webhook URLs must use HTTPS. HTTP URLs and URLs that resolve to private/internal IP addresses are rejected for security.

Available events

A webhook can subscribe to any event in the full catalog below, grouped the same way a project's Notifications settings group them when you choose what fires on a destination.

Experiment events

EventDescription
experiment.launchedExperiment transitioned from draft to running
experiment.pausedRunning experiment was paused
experiment.resumedPaused experiment was resumed
experiment.completedExperiment was stopped / completed
experiment.changes_publishedPending changes were published to a running experiment
experiment.visual_changes.savedA change set was saved in the visual editor
experiment.winner_declaredA winning variation was declared
experiment.results_readyThe daily results digest is ready to read
experiment.srm_failedSample ratio mismatch check failed, a sign traffic isn't splitting the way it should (safety)
experiment.guardrail_breachedA traffic guardrail was breached (safety)
experiment.guardrail_metric_breachedA guardrail metric failed its non-inferiority test (safety)
experiment.code_error_detectedA variation's code threw an error on a visitor's page (safety)

Safety events (marked above) ignore your organization's keyword filter on webhook routing and always fire, because a broken experiment is worth knowing about no matter what its name is.

Feature flag events

EventDescription
flag.publishedA feature flag's datafile was published to an environment
flag.rule_activatedA feature flag rule started serving
flag.srm_failedSample ratio mismatch check failed for a flag rule (safety)
flag.guardrail_breachedA traffic guardrail was breached for a flag rule (safety)
flag.results_readyThe daily results digest is ready for a flag rule

Organization events

EventDescription
exclusion_group.createdAn exclusion group was created
exclusion_group.updatedAn exclusion group was updated
exclusion_group.deletedAn exclusion group was deleted

Commerce & background-work events

These fire for the background jobs that keep your catalog, recommendations, and datasets fresh, so an import that fails or a sync that stops firing shows up in your own systems, not just in the dashboard.

EventDescription
commerce.import.completedA catalog import or product-cost sync finished
commerce.job_failedA background job failed (or a scheduled run stopped firing)
recommendation.run.completedA recommendation engine run finished
dataset.version.activatedA new dataset version went live
catalog.feed_sync.completedA product-feed sync finished for a project

Commerce events carry a lean, shared body: kind (the job type), projectId, projectName, link (a deep link into the dashboard), an optional reason (on commerce.job_failed), and an optional itemsSkipped (the number of rows an import dropped). The source object has type: "commerce".

Payload format

Every delivery is an HTTP POST with the following headers:

HeaderDescription
Content-Typeapplication/json
X-AvsB-Delivery-IdUnique delivery ID for deduplication, a bare id with no prefix (e.g. cm2a1b3c4d5e6f7g8h9i0j1k). Also part of the signed content.
X-AvsB-EventEvent type (e.g. experiment.launched)
X-AvsB-SignatureHMAC-SHA256 signature: sha256=<hex>
X-AvsB-TimestampISO-8601 timestamp. Also part of the signed content.
User-AgentAvsB-Webhooks/1.0

These are the only headers A vs B sets. A delivery never carries tracing headers such as sentry-trace, baggage or traceparent, and nothing from the request that triggered the event is passed on. Your server also sees the standard headers every HTTP client adds, such as Host, Content-Length and Accept-Encoding.

Example payload body:

JSON
{  "id": "cm2a1b3c4d5e6f7g8h9i0j1k",  "event": "experiment.launched",  "timestamp": "2026-04-12T14:30:00.000Z",  "project": {    "id": "clxyz456def",    "shortId": 200001,    "name": "Marketing Site"  },  "experiment": {    "id": "clxyz789ghi",    "shortId": 300001,    "name": "New Checkout Flow",    "status": "RUNNING",    "previousStatus": "DRAFT"  },  "triggeredBy": {    "id": "clxyzusr001",    "name": "Jane Smith",    "email": "jane@example.com",    "role": "ADMIN"  }}
JSON23 lines

Example commerce event body (a failed background job):

JSON
{  "id": "cm2b7h4k9m1n2p3q4r5s6t7u",  "event": "commerce.job_failed",  "timestamp": "2026-04-12T14:30:00.000Z",  "source": { "type": "commerce", "id": "clxyz456def", "name": "Shopify catalog import" },  "kind": "SHOPIFY_CATALOG_IMPORT",  "projectId": "clxyz456def",  "projectName": "Marketing Site",  "reason": "Shopify returned 402 payment required",  "link": "/projects/200001/commerce/jobs"}
JSON11 lines

A successful import uses event: "commerce.import.completed" with the same shape, no reason, and an optional itemsSkipped count of rows the import dropped.

Verifying signatures

Every delivery carries an X-AvsB-Signature header: sha256= followed by an HMAC-SHA256 hex digest, keyed by your webhook's signing secret. Always verify it before processing the payload.

What is signed

The signed content is three parts joined by dots, in this order:

Plain text
<X-AvsB-Delivery-Id>.<X-AvsB-Timestamp>.<raw request body>
Plain text1 line

This is the Standard Webhooks scheme. Signing the delivery id and the timestamp along with the body, rather than the body alone, is what makes a captured delivery unusable later: the timestamp is covered by the signature, so it cannot be rewritten to look fresh.

Two rules that matter in practice:

  1. Use the raw body bytes. Parsing the JSON and re-serialising it changes key order and whitespace, so the digest will never match. In Express, read the body with express.raw().
  2. Reject old deliveries. Compare X-AvsB-Timestamp to your own clock and reject anything more than 5 minutes old (in either direction, so a skewed clock is caught too). Without this check the timestamp being signed buys you nothing.

With @avsbhq/node

The Node SDK ships the whole check, including the tolerance window and a constant-time comparison:

TypeScript
import express from 'express';import { verifyWebhookSignature } from '@avsbhq/node';import { handleEvent } from './handleEvent';const app = express();const secret = process.env.AVSB_WEBHOOK_SECRET;if (!secret) throw new Error('AVSB_WEBHOOK_SECRET is not set');/** `express.raw()` leaves the body as the bytes that were signed. */interface RawBodyRequest {  body: Buffer;  headers: Record<string, string | string[] | undefined>;}interface WebhookResponse {  status(code: number): { send(body: string): unknown };  sendStatus(code: number): unknown;}app.post(  '/avsb-webhook',  express.raw({ type: 'application/json' }),  (req: RawBodyRequest, res: WebhookResponse) => {    const result = verifyWebhookSignature(req.body, req.headers, secret);    if (!result.ok) {      // result.reason is one of: missing_headers, malformed_signature,      // malformed_timestamp, timestamp_out_of_tolerance, signature_mismatch      return res.status(401).send(result.reason);    }    const event = JSON.parse(req.body.toString('utf8'));    // Deduplicate on result.deliveryId: retries reuse the same id.    handleEvent(event);    res.sendStatus(200);  },);
TypeScript36 lines

The tolerance defaults to 300 seconds. Pass { toleranceSeconds: 600 } to widen it, or { toleranceSeconds: 0 } to skip the age check entirely (only sensible if you deduplicate on the delivery id and accept replays).

By hand

Rolling your own check works the same way in any language: read the three headers, reject anything too old, recompute the digest, then compare it safely.

const crypto = require('crypto');const TOLERANCE_MS = 5 * 60 * 1000;/** * @param {Buffer} rawBody the request body, exactly as received * @param {Record<string, string | undefined>} headers * @param {string} secret * @returns {boolean} */function verifySignature(rawBody, headers, secret) {  const deliveryId = headers['x-avsb-delivery-id'];  const timestamp = headers['x-avsb-timestamp'];  const signature = headers['x-avsb-signature'];  if (!deliveryId || !timestamp || !signature) return false;  // Reject replays: the timestamp is signed, so it can be trusted once the  // digest matches, but the age still has to be checked.  const signedAt = Date.parse(timestamp);  if (Number.isNaN(signedAt)) return false;  if (Math.abs(Date.now() - signedAt) > TOLERANCE_MS) return false;  const expected = 'sha256=' + crypto    .createHmac('sha256', secret)    .update(`${deliveryId}.${timestamp}.${rawBody.toString('utf8')}`)    .digest('hex');  // Constant-time comparison. timingSafeEqual throws on a length mismatch,  // so check the length first.  if (signature.length !== expected.length) return false;  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));}
JavaScript32 lines

Retry policy

If your endpoint does not respond with a 2xx status code within 10 seconds, A vs B retries the delivery automatically, backing off further each time:

  • 1st retry: after 1 minute
  • 2nd retry: after 5 minutes
  • 3rd retry: after 30 minutes
  • 4th retry: after 2 hours
  • 5th retry: after 12 hours

After 6 failed attempts in total (the first delivery plus those five retries) the delivery is marked as Failed. You can retry failed deliveries by hand from the delivery log.

Auto-disable

When 5 deliveries in a row end as Failed, A vs B switches the webhook off rather than keep calling an endpoint that is not answering. The webhook detail view shows the reason. A delivery that succeeds sets the count back to zero, so it takes 5 failures with no success between them.

To re-enable, fix your endpoint and toggle the webhook back on. This resets the failure counter.

Delivery log

Open a webhook from Organization Settings > Integrations > Webhooks to reach its detail view. It includes a delivery log showing all deliveries from the last 30 days. Each entry shows the event type, delivery status, HTTP response code, number of attempts, and timestamp. Click Retry on any failed delivery to re-queue it.

The same view holds the masked signing secret with its Rotate button, the Send Test Ping button, and a Routed events section. Rotating and pinging act on the webhook itself, so they are always available. Routed events are per project, so that section has a project picker: choose a project to see what this webhook receives from it.

Secret rotation

If your signing secret is compromised, click Rotate in the webhook detail view (or call POST /api/v1/projects/{projectId}/webhooks/{webhookId}/secret) to generate a new one. In-flight deliveries continue using the old secret; every new delivery uses the new one. Update your endpoint with the new secret immediately.

The secret is shown once

The signing secret is returned when you create a webhook and when you rotate it, and nowhere else. Reading a webhook back over the API does not include it, so a read-only token cannot collect signing material for your endpoints. If you lose the secret, rotate to get a new one. The secret itself is a 64-character hex string with no prefix, which is what the dashboard says in place of the value it cannot show you.

Updating a webhook via the API

You can rename a webhook, change its URL or events, or turn it on and off from the API: PATCH /api/v1/projects/{projectId}/webhooks/{webhookId}. See the public API reference for the full request and response shape.

One request can edit and toggle

Your request body can mix enabled with name, url, or events. The content fields are applied first and the toggle second, and the response shows the state after both. If the new URL is refused, nothing is applied at all: the webhook keeps its old settings and stays on or off exactly as it was.

Concurrent edits are conditional when you want them to be. Reading a webhook returns an ETag header; send it back as X-Avsb-If-Match on the update and a webhook that changed since you read it is refused with 412 rather than overwritten. Leave the header off and the write is unconditional. The endpoint also accepts an Idempotency-Key header for retry safety.

Limits

  • Maximum 100 webhooks per organization
  • Delivery timeout: 10 seconds
  • Response body stored: first 1 KB (for debugging)
  • Delivery log retention: 30 days

Troubleshooting

  • Webhook creation fails with "URL must use HTTPS": only HTTPS URLs are supported. Set up TLS on your endpoint or use a service like ngrok for testing.
  • Test ping fails: check that your endpoint returns a 2xx status within 10 seconds. Check the delivery log for the response code and error message.
  • Webhook was auto-disabled: your endpoint has been failing consistently. Fix the endpoint, then re-enable the webhook from the toggle switch.
  • Duplicate events received: use the X-AvsB-Delivery-Id header or the id field in the payload to deduplicate. Retries send the same delivery ID.
Was this helpful?