API Reference
API Reference
GitHub
Campaigns

A/B Testing

Test email variants with split audience campaigns

An A/B test splits one campaign’s audience across up to five variants, each of which can change the subject line, the template, or both. Everything else about the send stays identical, which is what makes the comparison mean anything.

Configuring one

Variants are part of the campaign, so they are set at create time on a draft:

curl -X POST http://localhost:3000/api/v1/campaigns \
  -H "Authorization: Bearer myk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "April dispatch - subject test",
    "from_email": "news@example.com",
    "template_id": "0198f6a1-3c7e-7b21-9f4d-2a5c8e0b1d33",
    "list_id": "0198f6a2-7b19-7d02-9c31-4e8f1a6b3c22",
    "ab_test_enabled": true,
    "ab_variants": [
      { "name": "question", "subject": "Ready for April?",        "split_percentage": 50 },
      { "name": "direct",   "subject": "Your April dispatch",     "split_percentage": 50 }
    ]
  }'

What a variant may change

FieldNotes
nameRequired, 1-50 characters. It is the label in the results, so make it describe the arm
split_percentageRequired, 1-100
subjectAn alternative subject line. Rendered against the same data as the body
template_idAn entirely different template for this arm

subject is the usual test and the cheap one. template_id swaps the whole body, which tests layout or offer rather than wording — worth knowing that it makes the two arms differ in more ways than one, so a result tells you which template won and not why.

A variant that sets neither is a control: it sends exactly what the campaign would have sent anyway, which is a legitimate arm to include.

The rules

  • At least two variants. One arm is not a test.
  • At most five.
  • The splits may not exceed 100.

They do not have to add up to 100

Only exceeding 100 is refused. Splits of 10 and 10 are accepted, and the remaining 80% goes to the last variant — whatever is left over always does.

So a 10/10 split is not a small test on a large list. It is a 10/90 split with a misleading configuration. If you want a holdout, write it as an explicit arm.

How the split is made

At fan-out, the resolved audience is shuffled and then sliced in the order the variants are listed. The shuffle is what makes the arms comparable — without it the split would follow whatever order subscribers happened to be created in, which correlates with how long they have been a customer.

Every recipient gets exactly one variant, assigned once, before any mail is queued. A variant whose share rounds down to zero recipients still gets one, so a tiny list does not silently drop an arm.

Reading the results

Per-variant numbers are on the campaign route, not the analytics one:

GET /api/v1/campaigns/{id}
{
  "stats": { "sent": 4600, "failed": 50, "skipped": 50 },
  "stats_by_variant": {
    "question": { "sent": 2300, "failed": 25 },
    "direct":   { "sent": 2300, "failed": 25 }
  },
  "engagement": { "opened": 939, "clicked": 217 }
}

stats_by_variant counts delivery, not engagement

It breaks down the message statuses per arm — sent, failed, skipped, queued, pending — and nothing else. engagement sits beside it and is campaign-wide, so it cannot answer which arm did better.

Which is the whole question an A/B test asks. To answer it, export the per-recipient rows and group them yourself.

GET /api/v1/campaigns/{id}/messages?limit=200

Each row carries variant alongside opened_at and clicked_at — first-event stamps, so a row either has one or does not. Counting non-null stamps per variant, over rows whose status is sent, gives you the open and click rate per arm.

GET /api/v1/campaigns/{id}/analytics is a third readout — per-link click tallies and daily series for charting — and it is not broken down by variant either.