Discounts

Onboarding Discounts #

Classification: Internal-Only

The Welcome Discount is the percent an onboarding organization can get off its first paid checkout. Code: apps/bonsapi/src/service/onboarding/ (discount_engine.rs, discount.rs, offer_window.rs).

The discount #

The total is the referee %, plus the earned %, plus the fixed %, capped at 20. The cap is filled in that order: referee first, then earned, then fixed gets what is left.

Part Value Notes
Referee The referee’s % from the referral program, only while the referee has never paid Never expires and never claims the offer
Earned +1% per successful extraction, up to 20 Counted live until the offer starts
Fixed 20

Examples with earned and fixed both on:

Referee Earned Earned part Fixed part Total
0 0 0 20 20%
5 7 7 8 20%
10 20 10 0 20%

With both on, fixed alone already reaches the cap, so the total is always 20% and earned only changes the split. Turning a source off (see sources below) makes the total depend on the rest.

The earned and fixed percents are frozen when the offer starts. The referee % is never stored.

The offer #

POST /api/v1/onboarding/discount/offer starts the countdown. GET /api/v1/onboarding/discount reads the state. offer_status is one of:

Status Meaning
not_started No offer yet. Earned is live, nothing is frozen
running Before offer_expires_at
expired Past it (also an unreadable stored offer). Never restarts. Checkout still honours it for 10 seconds
claimed A paid checkout used the earned or fixed part. Wins over every other status
declined The buyer said no thank you before starting. Final

An organization gets at most one offer. Repeated or concurrent starts return the same one: the first offer written to the cache wins and every caller writes it to Clerk, so a retry after a failed write returns the same offer. The offer is kept in Clerk private metadata (onboarding_discount_* keys), never sent to the browser.

Decline #

POST /api/v1/onboarding/discount/decline closes an offer that has not started, for good. It sets onboarding_discount_declined on the Clerk organization. It uses the same first-wins cache entry as the start, so a decline and a start racing give one winner. A started, expired or claimed offer is left as it is, and claimed wins over declined. After a decline only the referee % counts, for display and for checkout.

Reset (non-production) #

POST /api/v1/onboarding/discount/reset forgets the offer, claimed and declined included, so it can be claimed again. It answers 403 when ENVIRONMENT_NAME is production.

Offer window experiment #

The length of the offer, and which parts it holds, come from one Doppler setting. Each organization gets one arm by a stable hash of the experiment name and its id, so the same organization always lands in the same arm, and a new experiment name reshuffles every organization.

Setting #

Doppler project bonsai, key ONBOARDING_OFFER_WINDOWS, on bonsapi and bonsapi-internal (through bonsai-secret). JSON:

{
  "experiment": "welcome_discount_window",
  "arms": [
    { "window": "15m", "weight": 50, "sources": { "earned": false, "fixed": true } },
    { "window": "2h", "weight": 25, "sources": { "earned": false, "fixed": true } },
    { "window": "until_trial_end", "weight": 25, "sources": { "earned": true, "fixed": true } }
  ]
}
Field Rules
experiment Required. 1 to 32 characters of a-z, 0-9, _. Salts the hash and tags the logs
arms[].window <n>s, <n>m, <n>h or <n>d (ends that long after the start, at most 365d), or until_trial_end (the start of the organization’s trial_expiry_date, UTC, as billing reads it)
arms[].name Optional. The arm’s name in offer_variant and the logs: letters, digits, _ and :, at most 16 characters. Default fixed:<window> or until_trial_end
arms[].sources Required on every arm: name both earned and fixed (booleans), at least one on. A disabled part counts 0 in the display, the offer and checkout for offers that have not started; a started offer keeps its frozen percents. This is how an experiment splits organizations between earned and fixed. There is no top-level sources
arms[].weight Whole number. Weights must sum to 100. A 0 arm is never assigned

The arm an organization gets depends on the experiment name, the order of the arms and their weights, so changing any of them reshuffles organizations that have not started an offer. Arm names must be unique, and no two arms may share both a window and the same sources (60m and 1h are the same window). A default name fits 16 characters only for a window of at most 10 characters (until_trial_end is exempt); give a longer window an explicit name. Unknown fields are rejected.

Unset, the default is experiment welcome_discount_window with one fixed:15m arm, earned off and fixed on (the only place that default exists). A blank value counts as unset. A malformed value does not stop the pod: it falls back to the default and logs onboarding_offer_windows_invalid at ERROR with result failure (the error and the value’s length, never the value).

With fixed off, an organization with no earned extractions yet has nothing to offer: the offer stays not_started until it has one. With earned only, the offer freezes whatever the organization has earned when it starts, so an early start gives a small percent.

Example: a split by discount source and window #

{"experiment":"welcome_discount_window","arms":[
  {"name":"fixed_1h","window":"1h","weight":17,"sources":{"earned":false,"fixed":true}},
  {"name":"fixed_2h","window":"2h","weight":17,"sources":{"earned":false,"fixed":true}},
  {"name":"earned_1h","window":"1h","weight":17,"sources":{"earned":true,"fixed":false}},
  {"name":"earned_2h","window":"2h","weight":17,"sources":{"earned":true,"fixed":false}},
  {"name":"both_15m","window":"15m","weight":16,"sources":{"earned":true,"fixed":true}},
  {"name":"both_30m","window":"30m","weight":16,"sources":{"earned":true,"fixed":true}}]}

Each organization lands in one arm; the offer is built from that arm’s sources and window, and offer_variant is the arm’s name. With both parts on, fixed alone already reaches the 20% cap, so a both arm gives the buyer the same percent as a fixed one. Earned varies per organization (0 to 20%) while fixed is always 20%, so arms that differ in source also differ in the discount amount.

Decisions #

  • until_trial_end ends at 00:00 UTC on the organization’s trial_expiry_date, computed by the same trial_expires_at helper billing uses to end the trial, so the offer and the trial end at the same instant.
  • The fixed % is the constant 20 (FIXED_DISCOUNT_PERCENT) and the cap is 20. There is no ONBOARDING_FLAT_OFFER_PERCENT; the Doppler setting only switches the earned and fixed parts on or off.
  • A decline is a stored status (onboarding_discount_declined in Clerk, offer_status declined), not only an Amplitude outcome.

Fallback #

until_trial_end falls back to a 15-minute window when the organization has no trial date or fewer than 15 minutes of trial are left. The offer keeps the assigned arm’s name and sources (so the organization stays in its arm for analysis), and onboarding_offer_window_fallback is logged with the reason (no_trial_date or trial_too_short); fell_back is true on the started log.

The arm and experiment are frozen on the offer #

offer_variant in the response is the arm, and it is stored with the offer in Clerk (onboarding_discount_variant), together with the experiment name (onboarding_discount_experiment). It is null before the offer starts and for offers started before arms existed. Changing the weights or the experiment name never moves a running countdown or relabels a running offer. The cache entry that makes the first offer win keeps its original four-token format so pods of different versions agree during a deploy; the experiment is stored in Clerk only. A retry after a failed Clerk write therefore stores the experiment configured at the retry.

Changing it #

  1. Pick the value and check it: mise run offer-windows-check '<json>' prints the experiment, the sources and each arm, or the error.
  2. Set ONBOARDING_OFFER_WINDOWS in the Doppler config for the environment (doppler secrets set ONBOARDING_OFFER_WINDOWS '<json>' --project bonsai --config <config>).
  3. Restart bonsapi and bonsapi-internal (rollout restart). Pods read it once at startup.
  4. Check that each pod logged onboarding_offer_windows_loaded with source: env and the expected arms. Use the same value for both services: bonsapi starts and declines offers, bonsapi-internal chooses the checkout coupon. Because the arm and experiment are frozen on the offer, a mismatch only affects offers started after it.

Set the key in Doppler before the rollout that removes the value from the deployment overlays. Until the key exists, pods run the default (fixed:15m, earned off, fixed on) and log onboarding_offer_windows_loaded at WARN, which is a change from the fixed:2h the overlays used to set.

Only organizations that start an offer after the restart get the new arms. Keep the experiment name while you compare results; change it to start a fresh experiment.

Reading the results #

Logs carry the arm so conversion per arm can be read from Datadog. Group by @data.variant (started and fallback logs) or @data.offer_variant (coupon and claimed logs), and filter by @data.experiment.

Log Fields
onboarding_offer_windows_loaded (startup, per pod) source (env or default), experiment, arms (variant, window, weight, sources)
onboarding_offer_windows_invalid (ERROR) error, value_len
onboarding_offer_started experiment, variant (from the stored offer), window, window_seconds and fell_back (only when this call’s own resolution matches the stored arm, and window_seconds only on the call that built the offer; null otherwise, for example on a retry after a failed Clerk write), plus the frozen percents and expiry
onboarding_offer_window_fallback experiment, variant (the assigned arm, kept), window (the fallback one), reason
onboarding_offer_declined experiment, assigned_variant (the arm the organization would have had)
onboarding_coupon_chosen experiment (the offer’s, else the configured one), offer_variant, the percents and flags
onboarding_offer_claimed experiment, offer_variant (both from the claimed offer), the checkout breakdown

The experiment on the started, coupon and claimed logs is the one frozen on the offer, so it stays correct after a rename. The declined and fallback logs have no stored offer, so they carry the experiment configured at the time. assigned_variant on the declined log is the arm the hash picked: a decline happens before any fallback could apply. Offers started before the experiment was stored carry none.

Conversion per arm is onboarding_offer_started to onboarding_offer_claimed. Decline rate per arm is onboarding_offer_declined over the organizations assigned to the arm. Add Datadog monitors, because a bad or missing Doppler value silently runs the default: @event_name:onboarding_offer_windows_invalid (any hit), and @event_name:onboarding_offer_windows_loaded @data.source:default (any hit in an environment that should have the key).

The webapp also sends onboarding_member_offer_* events to Amplitude with offer_variant.

Checkout #

The checkout route chooses the coupon (is_limited_discount_applied in POST /internal/api/v1/billings/checkout): while the offer is running, or within the 10 second grace, and the buyer applied it, one Welcome coupon for the combined percent is on the session, and it includes the referee %. Otherwise (declined, expired, claimed, not started, or the flag is false) the session has no coupon and coupon_id is null: the referee’s own coupon on the Stripe customer pays the first invoice, and referee_percent and is_referee_discount_applied still report it. A Welcome coupon missing from Stripe is an error, never a checkout without the discount.