Documentation
Experiments & Paywalls
A/B testing, audience targeting, and visual paywall configuration in Stackhouse-Billing.
Experiments & Paywalls
Stackhouse-Billing's growth suite adds four pieces on top of the core subscription model:
- Audiences — rule-based customer targeting
- Experiments — A/B tests whose variants point at existing offerings
- Paywalls — per-offering visual configuration with draft/publish
- Remote-config resolution — a customer endpoint that returns the right offering + paywall for that user
Data model
The new tables are created alongside the existing billing_* tables by the same idempotent migration:
billing_audiencesbilling_experimentsbilling_experiment_variantsbilling_experiment_assignmentsbilling_experiment_eventsbilling_paywalls
Offerings also gained an optional audience_id column, used to restrict an offering to a specific audience.
Audience rules
An audience stores an array of { field, op, value } rules. The matching engine evaluates rules against:
- customer
attributesJSONB - request context (
country,app_version) - derived flags (
is_existing_subscriber)
Supported operators are eq, neq, gt, gte, lt, lte, in, and exists. All rules in an audience must match for the customer to be included.
Bucketing
When a customer hits the per-user resolution endpoint:
- Existing experiment assignments are read first; if found, that variant is sticky.
- For each running experiment, the customer is checked against its optional audience.
- A deterministic hash of
(experiment_id, customer_id)maps to a weighted variant. - The assignment is persisted, so later traffic-weight changes do not re-bucket already-assigned customers.
The hashing function is a truncated SHA-256 digest; the same inputs always produce the same variant.
Events and results
POST /v1/billing/customers/:app_user_id/experiments/impression and
POST /v1/billing/customers/:app_user_id/experiments/conversion append rows to
billing_experiment_events.
GET /v1/billing/admin/experiments/:id/results returns per-variant counts and a
z-score for treatment variants versus control. These numbers are estimates based on
distinct-customer counts and should not be treated as rigorous statistical inference
without further analysis.
Endpoints
Customer
| Method | Endpoint | Notes |
|---|---|---|
| GET | /v1/billing/customers/:app_user_id/offerings/resolve?app_id=… | Returns { offering, paywall, experiment } |
| POST | /v1/billing/customers/:app_user_id/experiments/impression | Record an impression for the assigned variant |
| POST | /v1/billing/customers/:app_user_id/experiments/conversion | Record a conversion for the assigned variant |
Admin
| Method | Endpoint | Notes |
|---|---|---|
| POST / GET | /v1/billing/admin/audiences | Upsert / list audiences |
| POST / GET | /v1/billing/admin/experiments | Upsert / list experiments |
| POST | /v1/billing/admin/experiments/:id/status | Set draft, running, paused, or completed |
| GET | /v1/billing/admin/experiments/:id/results | Variant statistics |
| POST | /v1/billing/admin/offerings/:offering_id/audience | Attach an audience to an offering |
| POST / GET | /v1/billing/admin/paywalls | Upsert / get paywall config |
| POST | /v1/billing/admin/paywalls/:offering_id/publish | Promote draft to live config |
SDK usage
import { createClient } from '@stackhouse/js';
const stackhouse = createClient('http://localhost:8080');
const resolved = await stackhouse.billing.getResolvedOffering(
appId,
'user-42',
{ country: 'US', app_version: '1.2.0' },
);
// Paywall shown; later, when a purchase completes:
await stackhouse.billing.trackConversion(appId, 'user-42');import { Paywall, useExperimentConversion } from '@stackhouse/react';
function MyPaywall() {
const track = useExperimentConversion(appId, 'user-42');
return (
<Paywall
appId={appId}
appUserId="user-42"
context={{ country: 'US' }}
onConversion={track}
onSelectPackage={(pkg, offering) => startPurchase(pkg, offering)}
/>
);
}Admin UI
The billing admin interface is embedded at /admin/billing in the main Stackhouse
Explore UI. New tabs cover Audiences, Experiments, and Paywalls.