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
- Go to Organization Settings, then Integrations. Scroll to the Webhooks section.
- Click Create Webhook.
- Enter a name (e.g. "Slack Notifications") and the HTTPS URL of your endpoint.
- Choose a project, then tick the events that should fire on this webhook.
- Click Create Webhook. Copy the signing secret shown: you will need it to verify webhook signatures.
- Click Send Test Ping from the webhook detail view to verify your endpoint is receiving requests.
- The name is yours alone, and the URL is the endpoint that will receive the request.
- 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.
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
| Event | Description |
|---|---|
experiment.launched | Experiment transitioned from draft to running |
experiment.paused | Running experiment was paused |
experiment.resumed | Paused experiment was resumed |
experiment.completed | Experiment was stopped / completed |
experiment.changes_published | Pending changes were published to a running experiment |
experiment.visual_changes.saved | A change set was saved in the visual editor |
experiment.winner_declared | A winning variation was declared |
experiment.results_ready | The daily results digest is ready to read |
experiment.srm_failed | Sample ratio mismatch check failed, a sign traffic isn't splitting the way it should (safety) |
experiment.guardrail_breached | A traffic guardrail was breached (safety) |
experiment.guardrail_metric_breached | A guardrail metric failed its non-inferiority test (safety) |
experiment.code_error_detected | A 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
| Event | Description |
|---|---|
flag.published | A feature flag's datafile was published to an environment |
flag.rule_activated | A feature flag rule started serving |
flag.srm_failed | Sample ratio mismatch check failed for a flag rule (safety) |
flag.guardrail_breached | A traffic guardrail was breached for a flag rule (safety) |
flag.results_ready | The daily results digest is ready for a flag rule |
Organization events
| Event | Description |
|---|---|
exclusion_group.created | An exclusion group was created |
exclusion_group.updated | An exclusion group was updated |
exclusion_group.deleted | An 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.
| Event | Description |
|---|---|
commerce.import.completed | A catalog import or product-cost sync finished |
commerce.job_failed | A background job failed (or a scheduled run stopped firing) |
recommendation.run.completed | A recommendation engine run finished |
dataset.version.activated | A new dataset version went live |
catalog.feed_sync.completed | A 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:
| Header | Description |
|---|---|
Content-Type | application/json |
X-AvsB-Delivery-Id | Unique delivery ID for deduplication, a bare id with no prefix (e.g. cm2a1b3c4d5e6f7g8h9i0j1k). Also part of the signed content. |
X-AvsB-Event | Event type (e.g. experiment.launched) |
X-AvsB-Signature | HMAC-SHA256 signature: sha256=<hex> |
X-AvsB-Timestamp | ISO-8601 timestamp. Also part of the signed content. |
User-Agent | AvsB-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:
{ "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" }}Example commerce event body (a failed background job):
{ "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"}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:
<X-AvsB-Delivery-Id>.<X-AvsB-Timestamp>.<raw request body>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:
- 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(). - Reject old deliveries. Compare
X-AvsB-Timestampto 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:
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); },);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));}import hashlibimport hmacfrom datetime import datetime, timezoneTOLERANCE_SECONDS = 300def verify_signature(body: bytes, headers, secret: str) -> bool: delivery_id = headers.get('X-AvsB-Delivery-Id', '') timestamp = headers.get('X-AvsB-Timestamp', '') signature = headers.get('X-AvsB-Signature', '') if not (delivery_id and timestamp and signature): return False try: signed_at = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) except ValueError: return False age = abs((datetime.now(timezone.utc) - signed_at).total_seconds()) if age > TOLERANCE_SECONDS: return False signed_content = f"{delivery_id}.{timestamp}.{body.decode('utf-8')}" expected = hmac.new( secret.encode(), signed_content.encode(), hashlib.sha256 ).hexdigest() return hmac.compare_digest(signature, f"sha256={expected}")@app.route('/webhook', methods=['POST'])def handle_webhook(): if not verify_signature(request.data, request.headers, WEBHOOK_SECRET): return 'Invalid signature', 401 event = request.get_json() return 'OK', 200Retry 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 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.
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-Idheader or theidfield in the payload to deduplicate. Retries send the same delivery ID.