Affiliate program setup (Rewardful)

The runbook for turning on the affiliate program: the Rewardful account, the Stripe connection, the tracking script and the four pieces that live in this repo.

Everything a deployment needs before the affiliate program can pay anybody. Written as a runbook — do these in order, in Stripe test mode first.

The program itself is not ours. Rewardful sits on top of the same Stripe account billing already uses, and owns commissions, holding periods, refund clawback, payouts and tax paperwork. See ADR 0020 for why, and .fredrin/memory/concepts/affiliate-program.md for how the pieces fit together.

What is ours is four things, all of them already in the repo:

OursWhere
The public pageapps/web/src/app/(public)/affiliates/page.tsx
The program termsapps/web/src/app/(public)/terms/page.tsx (#affiliate-program)
Referral capture in the browserapps/web/src/components/affiliate-tracking.tsx, lib/affiliate.ts
Handing the referral to Stripeapps/api/src/modules/billing/billing.service.ts

Without the environment variables below the site still renders the page — the join button applies by email instead — and nothing is tracked. That is the safe default, and rolling the program back is removing the variables.

The program's terms live in code

packages/shared/src/schemas/affiliate.ts (AFFILIATE_PROGRAM) is the single source of the numbers the landing page and the Terms of Service state:

SettingValue
Commission20% of each payment
Durationfirst 12 months
Attribution window60 days, link or code
Hold before payout30 days
Payout cadencemonthly
Minimum payout$50, by PayPal or Wise

Rewardful's dashboard must be set to match these, because nothing in code can enforce them — they are the provider's settings. The defaults do not match: Rewardful ships a 30-day referral expiry, so the 60-day window is a deliberate change. If you change a number here, change it in both places, and remember an affiliate agreed to the old one.

1. Create the Rewardful account

  1. Sign up at getrewardful.com and connect the same Stripe account the product bills on. Connect test mode first.
  2. Create the campaign:
    • Reward: 20% recurring, limited to 12 months.
    • Cookie / referral expiry: 60 days.
    • Commission due after: 30 days.
    • Minimum payout: $50.
    • Affiliate discounts / coupon codes: see section 4a — codes are supported as of MARK-33M3ST, issued per affiliate on request.
  3. Turn on the hosted affiliate signup for the campaign, and copy its public signup URL.
  4. Copy the API key from Company settings (this is the public key the browser snippet uses, not a secret).

2. Set the environment variables

Web app only — all three are NEXT_PUBLIC_*, so they are inlined at build time and a change needs a redeploy.

VariableValue
NEXT_PUBLIC_REWARDFUL_API_KEYthe Rewardful API key; unset = no tracking at all
NEXT_PUBLIC_AFFILIATE_SIGNUP_URLthe hosted affiliate signup URL
NEXT_PUBLIC_AFFILIATE_LOGIN_URLhttps://app.getrewardful.com/login

The API needs nothing new: the referral rides the existing checkout request.

3. How attribution actually flows

The one rule everything below follows: Rewardful attributes a referral id, not an affiliate handle. client_reference_id is matched against the referral UUID Rewardful minted for that click and exposed as Rewardful.referral. A ?via= handle put in the same field matches nothing and silently earns nobody a commission. MARK-XS1GQK found the original implementation doing exactly that whenever their script had not resolved yet, which is why there are now two first-party cookies rather than one.

  1. A visitor lands on any page of the site with ?via=<handle>. Three things record it: Rewardful's own cookie (via rw.js), our first-party mos_via cookie holding the handle, and — once rw.js has resolved the visitor — our mos_ref cookie holding the referral id it minted.
  2. AffiliateTracking polls briefly after mount for Rewardful.referral and mirrors it into mos_ref. If Rewardful loads but reports no referral while we still hold a mos_via handle — their cookie was cleared, or the click happened on a page their script never ran on — it calls their documented rewardful('source', handle) to re-create the referral, and mirrors the id that produces. It deliberately does not live in a rewardful('ready') handler: their own docs warn ready never fires when the script is blocked, and that is half of what this exists for.
  3. A fresh ?via= click clears mos_ref. The id belongs to the previous affiliate and outranks the handle, so leaving it would break last-click-wins.
  4. The visitor signs up, verifies, onboards — possibly days later, possibly on the free plan for a while. The cookies carry the referral across all of it.
  5. When they upgrade, currentReferral() returns, in order: Rewardful's live referral, then mos_ref, then — only as forensics — the mos_via handle. The first two attribute; the third does not, and is sent anyway so a lost attribution can be reconstructed from metadata.referral after the fact.
  6. BillingService.createCheckoutSession puts that value in the Checkout Session's client_reference_id — the one field Rewardful reads — and also in metadata.referral. metadata.organizationId, which the Stripe webhook resolves the tenant from, is untouched on both paths.
  7. Rewardful matches the session, creates the referral, and from then on tracks every invoice that subscription pays.

4. What Stripe needs — and what it already has

MARK-XS1GQK's answer to "does anything need setting up on Stripe?".

Nothing about the Checkout Session needs changing. The referral rides client_reference_id, which the code already sends, and subscription_data already carries the metadata. Rewardful's server-side Checkout guide also asks for customer_creation: 'always'; that parameter exists only for mode: 'payment' sessions, and ours are mode: 'subscription' against a Customer we create ourselves in ensureStripeCustomer, so a Stripe Customer always exists for Rewardful to attach the referral to. No change required.

The one thing that does need doing is connecting Rewardful to Stripe, and it is done from Rewardful's dashboard, not Stripe's: Rewardful authorises through Stripe Connect and registers its own event subscriptions on its side. That connection is what carries invoice, refund and dispute notifications, so it is also the prerequisite for section 6 below. Connect test mode first; live mode is a second, separate authorisation.

Affiliate codes are Stripe promotion codes (MARK-33M3ST, ADR 0071, which reverses ADR 0020's link-only rule). Section 4a is the runbook.

State of the KeyesCode account as of 2026-09-18 — read from the dashboard and with the live restricted key, no writes:

CheckedFound
Installed apps (dashboard)One, Stripe MCP. No Rewardful.
Connect / authorized applicationsNone — the account is not a Connect platform and has authorised no third-party app
API keysOne restricted key, keyescode-marketing-os (the one in .env), plus other products' secret keys. No Rewardful.
Webhook endpoints on the live accountThree, none Rewardful's: this app's marketing-api.keyescode.com/webhooks/stripe, plus two other KeyesCode products
NEXT_PUBLIC_REWARDFUL_API_KEY in prodNot set (Railway, @marketing-os/web, production)
NEXT_PUBLIC_AFFILIATE_SIGNUP_URLNot set
NEXT_PUBLIC_AFFILIATE_LOGIN_URLNot set
https://marketing.keyescode.com/affiliatesRenders, with the mailto: "Apply to the program" fallback and no rw.js snippet

So the program is live as a page and dark as a program: Rewardful is wired to this Stripe account in no form at all — not an installed app, not an authorised Connect application, not an API key. Everything in sections 1 and 2 is still outstanding, and it is the only thing standing between the code below and a working program.

4a. Affiliate discount codes

An affiliate hands out a code like TORY10; the buyer gets the discount at Checkout and Rewardful credits the sale to the affiliate the code is assigned to. The code is a Stripe promotion code — code never creates one (the key in .env is a read-only restricted key, and issuing a discount is a human decision). Per affiliate, on request:

  1. Stripe → Product catalog → Coupons → New. Percent or amount off, duration (once / repeating / forever). One coupon can back many affiliates' codes if the terms are the same.
  2. On that coupon → Promotion codes → New. The customer-facing code, uppercase, A–Z 0–9 - _, at most 32 characters. Leave "first-time order only" off unless you mean it: a free-plan org that abandoned a Checkout already has a Stripe customer, and Stripe then refuses the code (the API returns INVALID_PROMO_CODE with Stripe's reason).
  3. Rewardful → the affiliate → Coupons → assign that promotion code to the affiliate. Rewardful's campaign-level "affiliate discounts" setting can instead generate a code for every affiliate who joins — only turn that on if every affiliate should get one.
  4. Give the affiliate the code and, optionally, a link that carries it: https://marketing.keyescode.com/welcome?via=<handle>&code=TORY10.

What our side does with it:

  • ?code= on any page is stored uppercased in the first-party mos_code cookie for the same 60 days as ?via= (newest wins).
  • Settings → Billing shows a Have a code? field pre-filled from that cookie, checked live against POST organizations/:orgId/billing/promo-code/validate (any member; a 10-minute per-process cache).
  • Checkout is created with discounts: [{ promotion_code }] and without allow_promotion_codes — Stripe refuses the pair. With no code, the session is unchanged and Stripe's own code field is still there. A code the API does not know is a 400, never a full-price Checkout. The 7-day trial and a code combine.
  • client_reference_id is untouched by a code: it is still the referral, or the org id.

Attribution precedence — first attribution wins, per Stripe customer. Rewardful's help centre ("How to use promotion codes for referral tracking") says the first affiliate a Stripe customer is assigned to receives commission on all later purchases: a customer attributed to affiliate A by link who later uses affiliate B's code gets B's discount, and A keeps the commission. Our Stripe customers are created by us before the first Checkout with no referral, so in practice the first paid Checkout decides. Not documented: which wins when one Checkout carries both a referral (client_reference_id) and another affiliate's code. Ask Rewardful support at activation and record the answer here; until then assume the referral, and the ToS promises only "first credited".

Codes are Checkout-only: an org already on a paid subscription changes plan in the Customer Portal, where the code field does not appear.

5. Verify in test mode

Steps 1–4 were verified by MARK-XS1GQK against the code; steps 5–7 need the Rewardful account from section 1 and remain outstanding.

  1. [verified] Open the site with ?via=<handle>; document.cookie has mos_via=<handle>, rw.js is loaded from r.wdfl.co, and once Rewardful.referral is a UUID it is mirrored into mos_ref. A second ?via= click clears mos_ref.
  2. [verified] With the three variables set, /affiliates renders "Become an affiliate" pointing at the hosted signup URL in a new tab, plus an "Affiliate sign in" link. With them unset it renders the mailto: fallback.
  3. [verified] A referred checkout sends client_reference_id = the referral and metadata = {organizationId, plan, interval, referral} on both the session and subscription_data; an unreferred one sends the org id and omits referral. The webhook still resolves the tenant from metadata.organizationId on the referred path.
  4. [verified] A referral containing anything outside [A-Za-z0-9_.@-], or a blank one, is rejected by CreateCheckoutSessionSchema before it reaches Stripe.
  5. [outstanding] Sign up a fresh organization and upgrade it with Stripe's 4242… test card; /settings/billing shows the paid plan.
  6. [outstanding] In Rewardful, the affiliate has one referral and one pending commission.
  7. [outstanding] Refund the test payment in Stripe and confirm the commission is reversed in Rewardful. See section 6.

6. Refunds and chargebacks

The terms promise that commission on a refunded, disputed or charged-back payment is reversed. Who delivers that:

  • Refunds — the provider, automatically. Rewardful documents that when Stripe notifies it of a refund it checks whether the refund relates to an invoice with a commission and recalculates that commission against the refunded amount, full or partial. A commission carries a state of pending, due, paid or voided with a voided_at, and a voided commission is excluded from payouts and from dashboard metrics. Requires the Stripe connection from section 4.
  • The 30-day hold is the real guard. A commission cannot be paid until it has cleared the hold, so an ordinary refund lands while the commission is still pending and nothing has to be chased.
  • Chargebacks — not documented by the provider, so treat them as manual. Rewardful's published material covers refunds; it does not state what a Stripe dispute does to a commission. Until that is confirmed with their support, the operating rule is: the hold covers the usual case, and a chargeback that lands on a commission is voided by hand in Rewardful (or via DELETE/PATCH on the commission through their REST API). If their support confirms automatic handling, record it here and delete this paragraph.
  • Codes versus cookies — asked in the same support ticket. Section 4a has the documented half (first attribution per Stripe customer wins) and the undocumented half (a referral and a different affiliate's code on the same Checkout). Record Rewardful's answer next to the chargeback answer.
  • Lengthen the hold if our refund policy ever exceeds 30 days. Rewardful's own guidance is to keep the hold at least as long as the refund window.

7. Going live

Repeat step 1 against live-mode Stripe, swap the API key and signup URL for the live campaign's, redeploy the web app, and announce it. Affiliates sign up and are paid entirely inside Rewardful; nothing about payouts touches this codebase.


Something wrong or missing on this page? Tell us and we will fix it.