Server-Side Delivery

The other analytics integrations in this section run in the visitor's browser: when somebody sees an experiment, we call the analytics tool already loaded on your page. That is free, instant, and simple, and it has one weakness. If a browser extension blocks that tool, or the visitor leaves before it loads, the event never happens.

Server-side delivery sends the same events a different way: from our servers to the destination, once the event has reached us. Nothing about that leg runs in the browser, so nothing in the browser can interfere with it. Two things it gives you:

  • Delivery an ad blocker cannot touch. The call happens between our servers and the destination.
  • Any tool you like. A signed webhook can feed a data warehouse, an internal service, a queue, or a tool we do not integrate with at all.

One thing it does not change: the event still has to reach us from the visitor's browser in the first place. Server-side delivery removes the second hop, the one to your analytics tool, not the first.

Every plan has server-side delivery, including the Free plan. It costs nothing extra on any plan.

Adding a destination

1

Open the section

Go to Project Settings → Integrations and find the Server-side delivery section.

2

Add a destination

Click Add destination and choose where events should go: your own webhook, Google Analytics 4, Mixpanel, or Amplitude.

3

Fill in the details

Give it a name (only you see it), then fill in the fields for that destination type. They are listed below.

4

Send a test event

Click Send test event on the saved destination. It sends one clearly marked test event and tells you exactly what the destination answered.

The Delivery log, expanded to show recent attempts.
  1. One attempt row in the Delivery log, showing when it ran, whether it was delivered, the status code, how many events it carried, and how long it took.

A destination change is saved straight away and reaches your live traffic within about a minute. You can turn a destination off without deleting it, and a destination that is off sends nothing.

Only webhooks let you choose which event types to send. Google Analytics, Mixpanel, and Amplitude destinations receive every type.

Webhook

Sends batches of events to an HTTPS endpoint you control, signed so you can prove they came from us.

FieldWhat to enter
Endpoint URLYour HTTPS endpoint, for example https://example.com/avsb-events. Plain http:// is refused.
Which events to sendTick the event types you want: experiment views, goals, purchases, recommendation views, recommendation clicks, custom events, and test events. Anything you do not tick is never sent.

When you save, a signing secret is shown once. Copy it then: we cannot show it again. If you lose it, or it leaks, edit the destination and tick Replace the signing secret. The replacement is shown once in the same way, and the old one stops working as soon as you save.

Google Analytics 4

Sends events into GA4 through Google's Measurement Protocol.

FieldWhat to enter
Measurement IDYour GA4 data stream ID, which looks like G-ABC123XYZ.
API secretCreate one in Google Analytics under Admin → Data Streams → your stream → Measurement Protocol API secrets.

Experiment views arrive as Google's own experience_impression event with exp_variant_string, so GA4's built-in experiment reporting picks them up with no extra setup. Everything else arrives under the standard names listed in Event names below.

Mixpanel

Sends events into Mixpanel through their import API, using a service account.

FieldWhat to enter
Mixpanel project IDThe numeric project ID from your Mixpanel project settings.
Service account usernameCreate a service account in Mixpanel with access to that project.
Service account passwordThe password shown when you create the service account. Mixpanel shows it once.

Experiment views arrive as $experiment_started with Experiment name and Variant name, which is the shape Mixpanel's own experiment reports expect.

Amplitude

Sends events into Amplitude through their HTTP API.

FieldWhat to enter
API keyThe API key from your Amplitude project settings.

Experiment views arrive as $exposure with flag_key and variant, the shape Amplitude's experiment analysis expects.

Event names

Server-side delivery uses one fixed set of names, whatever the destination:

What happenedName
A visitor saw an experimentavsb_experiment_viewed
A goal firedavsb_goal
A purchase was recordedavsb_purchase
A recommendation was shownavsb_rec_impression
A recommendation was clickedavsb_rec_click
A custom eventthe name you sent it with
A test sendavsb_test_event

Experiment views are the exception, because each tool has its own name for them: experience_impression in Google Analytics, $experiment_started in Mixpanel, and $exposure in Amplitude. Sending the name each tool expects is what makes their built-in experiment reports work.

Renaming events applies to browser-side forwarding only

These are the same names browser-side forwarding uses by default, so a tool receiving both paths sees one consistent event. If you have renamed events on an analytics provider card, that rename applies in the browser only: the same events delivered from our servers still arrive under the standard names above.

The one requirement for GA4, Mixpanel, and Amplitude

These three tools need their own visitor ID

Google Analytics, Mixpanel, and Amplitude each identify a person by an ID that their own script creates in the browser. We can only attach an event to the right person if we captured that ID while the visitor was on your site, which means the tool's own script has to be present on the page.

When the ID is missing, the event is skipped, not guessed. Inventing an ID would create a brand new phantom user in your reports and quietly corrupt your own numbers, so we refuse to do it. Every skipped record is counted, and the delivery log names the ID that was missing.

A webhook has no such requirement: the records carry our own visitor ID, and your endpoint receives every event you subscribed to.

What a webhook receives

Each delivery is an HTTP POST with Content-Type: application/json. The body is always an object with a data array, holding one or more event records. A single delivery can carry one record or many, so always loop over data.

JSON
{  "data": [    {      "experiment_id": "300001",      "experiment_name": "New Checkout Flow",      "variation_id": "400002",      "variation_name": "Single Page Checkout",      "event_type": "exposure",      "event_name": "avsb_experiment_viewed",      "event_id": "6f1c4b2e-7a58-4d0f-9c31-2b8f0a5d7e14",      "revenue": null,      "value": null,      "visitor_id": "9f8c1d2e4b7a",      "page_url": "https://example.com/checkout",      "timestamp": 1775049000000,      "sdk_version": "1.8.0"    },    {      "experiment_id": "",      "experiment_name": "",      "variation_id": "",      "variation_name": "",      "event_type": "purchase",      "event_name": "avsb_purchase",      "event_id": "b3d9a71c-05e6-4f22-8a44-1c7e6d90f3aa",      "revenue": 129.5,      "value": null,      "currency": "USD",      "transaction_id": "order_10482",      "visitor_id": "9f8c1d2e4b7a",      "page_url": "",      "timestamp": 1775049073000,      "sdk_version": "1.8.0"    }  ]}
JSON36 lines

Every record has these fields:

FieldTypeWhat it is
experiment_idstringThe experiment's short ID. Empty when the event is not tied to one experiment, as with purchases recorded at checkout.
experiment_namestringThe experiment's name. Falls back to the short ID if there is no name, and is empty only when experiment_id is empty.
variation_idstringThe variation's short ID, under the same rule as experiment_id.
variation_namestringThe variation's name, under the same rule as experiment_name.
event_typestringOne of exposure, goal, purchase, rec_impression, rec_click, custom, test.
event_namestringThe name of the event as it was sent, for example avsb_experiment_viewed.
event_idstringA unique ID for this event. Use it to remove duplicates, see below.
revenuenumber or nullMoney attached to the event, in your currency's main unit.
valuenumber or nullA plain numeric value attached to the event, when there is one.
currencystringPurchases only.
transaction_idstringPurchases only: your order ID.
goal_keystringGoals and custom events only: which goal or event key fired.
visitor_idstringOur anonymous visitor ID, stable across pages for the same visitor.
page_urlstringWhere it happened. Empty for events recorded away from a page, such as a checkout order.
timestampnumberWhen it happened, in milliseconds since 1 January 1970.
sdk_versionstringThe version of our code that produced it.
attributesobjectExtra properties attached to the event, when there are any.
sourcestringpreview or internal. Only present on your own QA traffic, so you can keep it out of your reports. Real visitor events never carry it.
testbooleanOnly present, and always true, on an event produced by the Send test event button.

Fields marked "only" are left out entirely when they do not apply, rather than sent as null.

Verifying a webhook

Every delivery carries two headers:

HeaderWhat it holds
X-Avsb-TimestampWhen we sent it, in seconds since 1 January 1970.
X-Avsb-Signaturev0= followed by a hex HMAC-SHA256 signature.

To check a delivery is genuinely ours, rebuild the signature yourself:

  1. Take the raw request body, exactly as it arrived. Do not parse and re-serialise it first: even a change in spacing breaks the signature.
  2. Join three pieces with colons: the literal text v0, the timestamp header, and the raw body. So v0:1775049000:{"data":[...]}.
  3. Compute an HMAC-SHA256 of that string using your signing secret, as lowercase hex.
  4. Compare v0= plus your result against the X-Avsb-Signature header, using a timing-safe comparison.
  5. Reject anything whose timestamp is more than five minutes away from your own clock, in either direction. That stops somebody replaying a delivery they captured earlier.

In Node.js, in an Express handler. express.raw() matters: express.json() parses the body and throws away the original bytes the signature is built from.

JavaScript
const crypto = require('crypto');const express = require('express');const app = express();const SECRET = process.env.AVSB_DELIVERY_SECRET;const TOLERANCE_SECONDS = 5 * 60;/** * @param {string} rawBody the request body, exactly as received * @param {string} timestamp * @param {string} signature * @returns {boolean} */function isFromAvsb(rawBody, timestamp, signature) {  // With no secret configured there is nothing to verify against: reject.  if (!SECRET) return false;  // Replay guard: reject deliveries more than five minutes old or ahead.  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;  const expected =    'v0=' +    crypto      .createHmac('sha256', SECRET)      .update(`v0:${timestamp}:${rawBody}`, 'utf8')      .digest('hex');  // Timing-safe comparison; lengths must match before comparing bytes.  if (signature.length !== expected.length) return false;  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));}/** * @param {{ body: Buffer, headers: Record<string, string | undefined> }} req * @param {{ status(code: number): { send(body: string): unknown } }} res */function handleDelivery(req, res) {  const signature = req.headers['x-avsb-signature'];  const timestamp = req.headers['x-avsb-timestamp'];  const rawBody = req.body.toString('utf8');  if (!signature || !timestamp || !isFromAvsb(rawBody, timestamp, signature)) {    return res.status(401).send('Invalid signature');  }  const { data } = JSON.parse(rawBody);  for (const record of data) {    // Handle each record, keyed on record.event_id so retries are harmless.  }  res.status(200).send('OK');}app.post('/avsb-events', express.raw({ type: 'application/json' }), handleDelivery);
JavaScript55 lines

Answer with any 2xx status once you have accepted the batch. Anything else is treated as a failure and shows up in your delivery log.

Duplicates and event_id

Delivery is at least once. If a destination is briefly unreachable, times out, or answers with a server error, we retry, up to five times before giving up, and a retry can deliver a record you already have. A destination that rejects a record outright is not retried: the delivery log records the reason instead, because resending something that was refused cannot succeed.

Every record carries an event_id that never changes between attempts. Count distinct event_id values, or store them and ignore ones you have seen, and duplicates stop mattering. That same event_id is the one attached to the event in your A vs B results, so it is also how you reconcile the two sides.

For Mixpanel and Amplitude we pass the event_id through as their own duplicate key, so they drop repeats for you. Google Analytics has no such key, so a retry after a partial failure can count an event twice there. It is rare, it only happens after a failed attempt, and it is the reason we would rather retry than quietly drop your data.

The delivery log

Each destination keeps a log of its recent sends, under Delivery log on the destination card. It shows the last 50 attempts, with:

  • When the attempt happened
  • Result, delivered or failed
  • Status, the code the destination answered with
  • Events, how many records were in that attempt
  • Time taken, the round trip
  • Details, the reason for a failure, or a note about records that were skipped for a missing visitor ID

A badge above the log gives you the one-glance answer: delivering, some sends failed, or all recent sends failed.

Log entries are kept for 30 days and then removed.

Testing a destination

Send test event delivers one clearly marked event, named avsb_test_event, through exactly the same path a real event takes. That is the point: a green test proves the real path works, not a simplified version of it. The result tells you whether the destination accepted it, the status code, and how long it took, and the attempt is written to the delivery log like any other.

Google Analytics tests are run against Google's own validation endpoint. Google checks the event and reports any problems with it, and does not add it to your reports, so you can test as often as you like without polluting your data. Any complaints Google makes are shown to you word for word.

A webhook receives the test event even if Test events is not one of the types it subscribes to. You asked to test that endpoint, so the button is never a silent no-op. The record carries "event_type": "test" and "test": true, which is all your handler needs to ignore it.

If your plan changes

Server-side delivery stops when a plan no longer includes it. Your destinations and their settings stay where they are, and nothing more is sent until the feature is available again. The section shows an upgrade card in place of the destination list while that is the case.

  • Why counts differ between tools, for reconciling your A vs B numbers with a destination's numbers.
  • Webhooks, which are a different feature: those notify you when an experiment is launched, paused, or completed, rather than streaming visitor events.
Was this helpful?