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:
| Ours | Where |
|---|---|
| The public page | apps/web/src/app/(public)/affiliates/page.tsx |
| The program terms | apps/web/src/app/(public)/terms/page.tsx (#affiliate-program) |
| Referral capture in the browser | apps/web/src/components/affiliate-tracking.tsx, lib/affiliate.ts |
| Handing the referral to Stripe | apps/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:
| Setting | Value |
|---|---|
| Commission | 20% of each payment |
| Duration | first 12 months |
| Attribution window | 60 days, link or code |
| Hold before payout | 30 days |
| Payout cadence | monthly |
| 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
- Sign up at getrewardful.com and connect the same Stripe account the product bills on. Connect test mode first.
- 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.
- Turn on the hosted affiliate signup for the campaign, and copy its public signup URL.
- 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.
| Variable | Value |
|---|---|
NEXT_PUBLIC_REWARDFUL_API_KEY | the Rewardful API key; unset = no tracking at all |
NEXT_PUBLIC_AFFILIATE_SIGNUP_URL | the hosted affiliate signup URL |
NEXT_PUBLIC_AFFILIATE_LOGIN_URL | https://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.
- A visitor lands on any page of the site with
?via=<handle>. Three things record it: Rewardful's own cookie (viarw.js), our first-partymos_viacookie holding the handle, and — oncerw.jshas resolved the visitor — ourmos_refcookie holding the referral id it minted. AffiliateTrackingpolls briefly after mount forRewardful.referraland mirrors it intomos_ref. If Rewardful loads but reports no referral while we still hold amos_viahandle — their cookie was cleared, or the click happened on a page their script never ran on — it calls their documentedrewardful('source', handle)to re-create the referral, and mirrors the id that produces. It deliberately does not live in arewardful('ready')handler: their own docs warnreadynever fires when the script is blocked, and that is half of what this exists for.- A fresh
?via=click clearsmos_ref. The id belongs to the previous affiliate and outranks the handle, so leaving it would break last-click-wins. - 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.
- When they upgrade,
currentReferral()returns, in order: Rewardful's livereferral, thenmos_ref, then — only as forensics — themos_viahandle. The first two attribute; the third does not, and is sent anyway so a lost attribution can be reconstructed frommetadata.referralafter the fact. BillingService.createCheckoutSessionputs that value in the Checkout Session'sclient_reference_id— the one field Rewardful reads — and also inmetadata.referral.metadata.organizationId, which the Stripe webhook resolves the tenant from, is untouched on both paths.- 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:
| Checked | Found |
|---|---|
| Installed apps (dashboard) | One, Stripe MCP. No Rewardful. |
| Connect / authorized applications | None — the account is not a Connect platform and has authorised no third-party app |
| API keys | One restricted key, keyescode-marketing-os (the one in .env), plus other products' secret keys. No Rewardful. |
| Webhook endpoints on the live account | Three, none Rewardful's: this app's marketing-api.keyescode.com/webhooks/stripe, plus two other KeyesCode products |
NEXT_PUBLIC_REWARDFUL_API_KEY in prod | Not set (Railway, @marketing-os/web, production) |
NEXT_PUBLIC_AFFILIATE_SIGNUP_URL | Not set |
NEXT_PUBLIC_AFFILIATE_LOGIN_URL | Not set |
https://marketing.keyescode.com/affiliates | Renders, 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:
- 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. - 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 returnsINVALID_PROMO_CODEwith Stripe's reason). - 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.
- 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-partymos_codecookie 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 withoutallow_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_idis 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.
- [verified] Open the site with
?via=<handle>;document.cookiehasmos_via=<handle>,rw.jsis loaded fromr.wdfl.co, and onceRewardful.referralis a UUID it is mirrored intomos_ref. A second?via=click clearsmos_ref. - [verified] With the three variables set,
/affiliatesrenders "Become an affiliate" pointing at the hosted signup URL in a new tab, plus an "Affiliate sign in" link. With them unset it renders themailto:fallback. - [verified] A referred checkout sends
client_reference_id= the referral andmetadata={organizationId, plan, interval, referral}on both the session andsubscription_data; an unreferred one sends the org id and omitsreferral. The webhook still resolves the tenant frommetadata.organizationIdon the referred path. - [verified] A referral containing anything outside
[A-Za-z0-9_.@-], or a blank one, is rejected byCreateCheckoutSessionSchemabefore it reaches Stripe. - [outstanding] Sign up a fresh organization and upgrade it with Stripe's
4242…test card;/settings/billingshows the paid plan. - [outstanding] In Rewardful, the affiliate has one referral and one pending commission.
- [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
stateofpending,due,paidorvoidedwith avoided_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
pendingand 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/PATCHon 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.