docs
/
AI skills

Affiliate and referral program

A program that pays people for the customers they send. Its commission, hold and checks; affiliates added by staff or joining from their account; codes and links; how a paid order becomes a commission in the affiliate's wallet; approving, holding and reversing commissions; and the payout, which staff send from Finance.

An affiliate program has three kinds of record:

RecordWhat it is
Program (affiliate_program)The deal: what an affiliate earns, how long it is held, what is checked. Known by its name, which affiliates and referrals point to.
Affiliate (affiliate)One person on one program: email, status, codes, totals. A person on two programs is two affiliates.
Referral (affiliate_referral)One click or one order credited to an affiliate, with its commission and history.

The money moves like this. A visitor opens a link with ?ref=<code>. The site keeps the code in a cookie. When their order is paid, the engine writes a referral with the commission. The commission is held, then credited to the affiliate's wallet, an internal balance in Appmint. Money leaves only when the affiliate asks for a payout and staff approve and send it from Finance. Nothing in this skill moves real money. The payout in step 8 does, and only a person sends it.

Customers see their side in My Account › Affiliates (/account/affiliates): the programs they joined, their code and link, referrals, wallet, payout methods and payout requests. See The customer account portal.

Every staff call below is call_appengine on affiliate/…. These routes are staff only.

Ask first

  • What does an affiliate earn? A percentage of each sale (say 10%), with a cap per order or not. A percentage that rises with their sales (tiers). A flat amount per order works only for single affiliates for now (see the warning in step 2).
  • How long is a commission held before it counts, so refunds can come back first? 0 days, 7, 30…
  • Who can join? Anyone with an account, from their account page, with staff approving each one. Or only people staff add.
  • Who are the first affiliates? Name and email for each, and a code they want ("SARAH10") if any.
  • Repeat customers: should an affiliate earn on a customer's second order? The default says no (see checks).
  • Can an affiliate earn on their own orders? Default no.
  • Does the new customer get anything? A discount for using the link has to be a discount code they type (see step 2, Referred customer reward).
  • How are affiliates paid? PayPal or a bank account (ACH): the only two ways payouts are sent. And a minimum amount before a payout, set on each wallet.

Steps

1. CRM › Affiliates: see what exists

Open CRM › Affiliates (/affiliates). Tabs: Dashboard, Programs, Affiliates, Referrals.

GET affiliate/programs lists programs (status, type filters). GET affiliate/stats gives the totals across programs; GET affiliate/stats/program/<name> one program's affiliates, referrals, conversions, revenue and commission. GET affiliate/affiliates?search=<words> searches affiliates by name, email or code.

A program already doing the job is changed, not copied: a second program splits the affiliates and the numbers.

Hands on: the program names in use (a name is unique). Check: the Programs tab shows the same programs.

2. CRM › Affiliates › Programs: the program, as a draft

POST affiliate/programs. Leave status out: a new program is a draft. Codes on a draft program don't work, so nobody earns yet.

{
  "name": "partners",
  "title": "Partner Program",
  "description": "Earn 10% on every order from people you send us.",
  "commission": { "type": "percentage", "percentage": 10, "maxCommission": 50 },
  "attribution": { "windowDays": 30, "model": "last_click", "allowSelfReferral": false },
  "payout": { "holdDays": 30, "minPayout": 25, "autoCredit": true },
  "fraud": { "maxReferralsPerDay": 50, "requireUniqueEmail": true, "requirePaidOrder": true, "minOrderAmount": 0, "blockSameIP": false },
  "terms": { "requiresAgreement": true, "agreementText": "<p>…</p>" }
}

It answers the saved record. Program "partners" already exists means the name is taken. On screen: Programs › New Program, one page of sections with the same fields. The form also starts as Draft.

What each part does (read from affiliate.service.ts 70–124 and affiliate-tracking.service.ts; the earn rules are tested):

FieldDoesNotes
nameThe program's key. Affiliates and referrals store it.Don't rename a program with affiliates: they keep the old name and stop earning.
title, displayName, tagline, description, logo, coverImage, benefits, bonuses, resources, contactName, contactEmailShown on the join screen and the sign-up banner.bonuses are shown, never paid.
statusdraft, active, inactive. Only active earns and can be joined.
typereferral, affiliate, hybrid. A label.Changes nothing.
commission.typepercentage (percentage), tiered (tiers), flat (flatAmount).See the warning below for flat.
commission.maxCommissionThe most one order can earn.
commission.tiers[{ "minConversions": 0, "maxConversions": 9, "percentage": 10 }, { "minConversions": 10, "percentage": 15 }]The first tier that fits the affiliate's paid orders so far.
payout.holdDaysDays a commission is held after the order.Default 7.
payout.autoCreditWith holdDays: 0, true credits the wallet the moment the order is paid.With any hold, commissions wait for staff (step 7).
payout.minPayoutShown on the form.Not checked. The floor a payout request is held to is the wallet's own minimum payout, set in Finance.
attribution.allowSelfReferralfalse refuses an order whose email or customer is the affiliate's own.
attribution.windowDaysHow long a click stays open to be matched to an order.The site's cookie always lasts 30 days, and an order carrying the code is credited whatever this says.
attribution.modellast_click or first_click.Stored only. Each new valid link replaces the cookie, so the last link clicked wins.
fraud.*See checks.blockSameIP and requirePaidOrder are stored only; orders earn only when paid anyway.
trackingCookieAgeStored.The cookie is 30 days whatever it says.
terms.requiresAgreement, agreementText, agreementUrlThe join screen shows a tick box ("I agree") unless requiresAgreement is false.The tick isn't saved on the affiliate.
terms.autoApprove, maxAffiliates, applicationDeadlineStored.Not applied: every new affiliate is pending, with no cap or deadline.
referredRewardnone, discount_code (discountCode), wallet_credit (walletAmount). Shown on the sign-up banner.Nothing gives it to the new customer. For a discount, make a code with Discount codes and have the affiliate hand it out with their link.
products[] (with commissionOverride)Shown on the program.Not used in the commission.

A flat program pays nothing on real orders. Store orders always carry their lines, and the per-line calculation finds no rate for flat, so the commission is $0 (tested: a $15 flat program earned $0 on a $100 order, and $15 only on a test order with no lines). Use percentage or tiered. For a flat amount, use a per-affiliate override (step 4), which pays per line.

To change a program later: PUT affiliate/programs/<name> with the fields to change. A nested part (commission, payout, attribution, fraud, terms) is replaced whole, so send all its fields: { "payout": { "holdDays": 0 } } on its own drops autoCredit, and commissions stop being credited.

Hands on: the program name. Check: GET affiliate/programs/<name> returns it as draft with the commission, hold and checks as agreed. It shows on the Programs tab.

3. CRM › Affiliates › Programs: switch it on

Stop: once active, every approved affiliate's code earns on every paid order, and customers can join from their account page. Tell the owner the deal in plain words ("10% of each order up to $50, held 30 days, not on their own orders or a customer's second order") and wait for yes.

PUT affiliate/programs/<name> with { "status": "active" }. inactive turns it off: codes stop resolving and orders stop earning; nothing already earned changes.

Hands on: the active program. Check: GET affiliate/programs/<name> reads status: "active".

4. CRM › Affiliates › Affiliates: the affiliates

Staff add one. POST affiliate/affiliates:

{ "email": "[email protected]", "name": "Sarah Jones", "program": "partners", "customerId": "<customer sk>", "code": "SARAH10" }
  • program is required. On screen, Register Affiliate picks a customer and a program; its Default (auto-select) option is refused (Program name is required), so pick the program.
  • Pass customerId when the person has a customer account. Their wallet is then their own and shows in their account; without it the wallet belongs to the affiliate record, and their account page doesn't find it.
  • code is optional (a random 8-character code otherwise). The same email can't join one program twice.

Customers join themselves. Signed in, on My Account › Affiliates, they pick a program and press Join Program (POST client/affiliate/join through the site's /api, tested). They can't choose their code; staff add one later. To invite people, send /register?affiliate=<program name>: the sign-up page shows the program, and after sign-up they land on its join screen. Nobody on staff is told when someone joins: look for Pending on the Affiliates tab.

Every new affiliate is pending: their code doesn't work yet. Registering emails and texts them the affiliate-signup message.

Stop: approving tells the person they're in and makes their code earn. Show the owner who will be approved and wait for yes.

ActionCallMessage sent (email, and text when they have a phone)
ApprovePOST affiliate/affiliates/<id>/approveaffiliate-approved
Decline an applicationPOST affiliate/affiliates/<id>/reject { "reason": "…" } (kept on the record)affiliate-rejected
Suspend an active affiliate (codes stop working)POST affiliate/affiliates/<id>/suspendaffiliate-suspended
Reactivate (also takes back a decline)POST affiliate/affiliates/<id>/reactivateaffiliate-reactivated
Own commission ratePUT affiliate/affiliates/<id>/commission-override { "type": "percentage", "value": 15 } or { "type": "flat", "value": 5 }none

Set a flat override with the call, not the screen. The Commission Override drawer's Fixed Amount saves "fixed", which the engine reads as a percentage: a $5 override earned 5% (tested). Send "type": "flat". A flat override pays its amount per line of the order (tested: two lines, $10).

Hands on: each affiliate's id (sk) and main code. Check: GET affiliate/affiliates/<id> reads status: "active", with primaryCode. On screen, the affiliate's drawer (Overview, Customer, Wallet & Payments, Stats, Links, Referrals, Settings).

An affiliate can hold several codes: one they say out loud, one per campaign. Codes are unique across the business, matched without regard to case, 3 to 32 letters, digits, - or _, and some words are reserved (admin, test, checkout, ref…). GET affiliate/codes/check?code=SARAH10&affiliateId=<id> answers { available, reason } before saving.

JobCall
Add a code (it becomes the main one; the old main code is retired, which still credits them)POST affiliate/affiliates/<id>/codes { "code": "SARAH10", "label": "personal" }; "makePrimary": false adds it without changing the main code
Make another code the main onePUT affiliate/affiliates/<id>/codes/primary { "code": "…" }
Stop advertising a code but keep it creditingPUT …/codes/status { "code": "…", "status": "retired" }
Kill a code (it credits nobody)PUT …/codes/status { "code": "…", "status": "disabled" }. The main code can't be stood down; make another main first.
A fresh random codePOST affiliate/affiliates/<id>/codes/reset
"They lost the email"POST affiliate/affiliates/<id>/codes/send: re-sends the current code and link, changes nothing

Adding, resetting and sending email and text the affiliate (affiliate-code-added); disabling sends affiliate-code-disabled. Codes are never deleted. On screen: the affiliate's Overview codes panel, and Links.

The link. POST affiliate/links/generate with { "code": "SARAH10" } (or affiliateId), and where it points: page, url, or productSlug. It is built on the site's own address (tested: https://<site>/shop?ref=SARAH10). The affiliate must be active.

productSlug builds /product/<slug>, which doesn't open the product on a site whose product page reads ?slug= (tested). For a product, send "page": "/product?slug=<slug>"; the link comes back as /product?slug=<slug>&ref=SARAH10. Any page of the site works: ref can ride on any address.

Hands on: the link and code to give each affiliate. Check: open the link in a private window. The page loads, and the browser now holds an affiliateCode cookie with the code (tested). An unknown or disabled code sets no cookie.

6. Site checkout: try the program before telling anyone

What happens on the site (AffiliateTracker.tsx, mounted on every page):

  1. A page opened with ?ref=<code> asks GET affiliate/public/resolve/<code>. Only a code of an active affiliate on an active program is kept, in the affiliateCode cookie for 30 days.
  2. It records a click (POST affiliate/public/track): a pending referral for that visitor. The same visitor coming back renews it rather than counting twice (tested).
  3. The built-in checkout sends the cookie's code with the order (affiliateCode). A checkout built on the page runtime (window.appmint.storefront.checkout) doesn't add it: put affiliateCode (read from the cookie) in the checkout input.
  4. When the order is paid, by card at checkout or later when staff record the payment, the engine runs the checks and writes the commission. An unpaid order (pay later) earns when it is paid. A referral is written once per order.

To see the commission without a real sale, use the staff test call. It runs exactly what a paid order runs, on a made-up order:

POST affiliate/test/attribute-order
{
  "affiliateCode": "SARAH10",
  "orderNumber": "TEST-1",
  "orderAmount": 120,
  "customerEmail": "[email protected]",
  "items": [{ "sku": "<SKU>", "unitPrice": 60, "quantity": 2 }]
}

It writes a real referral on that affiliate and changes their totals. Use a test affiliate with a @test.local email on the program, and a hold of at least a day so nothing is credited. Then read GET affiliate/referrals?affiliateId=<id>: status, commissionAmount, and flagReason when it was refused. Afterwards reject the test referral (step 7) or delete the test affiliate and its referrals.

Check: the commission is what the owner expects for that basket (see How the commission is worked out), and a second order from the same email is refused with the reason the owner expects.

7. CRM › Affiliates › Referrals: commissions, day to day

Each referral moves through these states (status):

StatusMeaningMoney
pendingA click, no order yetnone
commission_heldA paid order; inside the hold period, or waiting for staffexpectedAmount
qualifiedApproved with no commission, or about to be creditedexpectedAmount
commission_paidCredited to the wallet (not yet paid out)paidAmount
rejectedRefused by a check, rejected or reversed by stafflostAmount
expiredA click that never became an ordernone

Releasing held commissions. Nothing releases them by itself: no job runs on a timer. Either approve referrals one by one, or press Process Held Commissions (Dashboard, or the Referrals tab) to credit every held commission whose hold has passed: POST affiliate/commissions/process-held, which answers { processed, skipped, errors }. It credits them whatever autoCredit says.

Stop: approving or processing puts money in affiliates' wallets, which they can then ask to be paid. Show the owner the referrals and total first and wait for yes.

POST affiliate/referrals/<id>/transition with { "action": "…", "reason": "…" }. Bulk: POST affiliate/referrals/bulk-transition with { "filter": { "ids": […] } or { "status", "program", "affiliateId", "from", "to" }, "action": "…" }.

actionFromDoes
approveheld, qualified, pendingCredits the wallet now (commission_paid), emails the affiliate referral-approved
rejectany but rejectedTakes back a credited commission from the wallet; emails referral-rejected
holdcommission_paidTakes it back out of the wallet and holds it again
reverseanyFor a refund or chargeback: takes back a credited commission; marked reversed. No email.
restorerejected, expiredBack to held
expirependingCloses a click
recalculateheld, qualified, pendingWorks the commission out again from the program
override-amountheld, qualified, pendingSets the amount ("amount": 35)

Taking money back can leave a wallet below zero; it is set against later commissions. Every action is kept in the referral's history. On screen, each row's menu offers only the actions its status allows, and the Commission health box (GET affiliate/referrals/audit/report) lists zero commissions on orders, holds past their window, clicks past their expiry and held referrals with no order.

A refund doesn't touch the commission. When an order is refunded or cancelled, reverse its referral by hand. To find it: GET affiliate/referrals?affiliateId=<id> and match orderNumber.

Hands on: the referrals credited. Check: the referral reads commission_paid, and the affiliate's drawer › Wallet & Payments shows the balance up by that amount (tested: approve credited $20, hold took it back to $0).

8. Finance: the payout

The affiliate adds a payout method and presses Request Payout in My Account › Affiliates › Wallet. Staff see the request in Finance › Payouts and take it through pending, approved, processing and completed (or failed, cancelled). Every step, the payout rules, PayPal and ACH files are in Payouts. See also Finance.

Stop: a payout sends real money to a real account. Never approve, process or complete one for the owner: show the request (who, how much, which method) and let a person do it.

How the commission is worked out

For each paid order (calculateCommission, affiliate-tracking.service.ts 893–1026), line by line:

  1. A line's value is its unit price times quantity. Discounts, tax and shipping aren't taken off (tested: two $100 items with $20 off the line earned 10% of $200).
  2. The rate: the affiliate's override if they have one, else the program's percentage, else the tier that fits their paid orders so far.
  3. A flat override pays its amount once per line.
  4. maxCommission caps the order's total (tested: 10% of $1,000 capped at $50).

The product's own Eligible for affiliate commissions switch and percentage (Product › Affiliate) aren't used: every line earns at the program's rate.

What is checked before an order earns

In order. A refused order still writes a referral, rejected, with flagReason, so the owner can see why.

CheckRefused whenflagReason
The codeunknown, disabled, affiliate not active, program not activenone written
allowSelfReferral: falsethe order's email or customer is the affiliate'sSelf-referral not allowed (tested)
requireUniqueEmail: truethis affiliate already earned on that emailCustomer already referred by this affiliate (tested: a customer's second order earns nothing)
maxReferralsPerDaythis affiliate has that many referrals today; clicks countDaily referral limit exceeded
minOrderAmountthe order total is below itOrder amount below minimum (…)

When it's done

Tell the person:

  • the program in plain words: rate, cap, hold, who can join, what is refused;
  • each affiliate, their code and their link;
  • whether the program is on, and who was approved;
  • the test result: basket, commission, and that the test referral was rejected or deleted;
  • that held commissions need Process Held Commissions (or approval) to be credited, and refunds need a reverse.

Open it in CRM › Affiliates (/affiliates).

What it can't do yet

  • A flat program pays $0 on store orders (step 2). Studio's Fixed Amount override is read as a percentage (step 4).
  • Product-level commission settings are ignored, and line discounts, coupons and refunds don't change a commission.
  • Held commissions aren't released on a schedule, and clicks aren't expired on a schedule: staff press the buttons.
  • Stored but not applied: auto-approve, maximum affiliates, application deadline, first-click attribution, cookie age, same-IP blocking, the program's minimum payout, performance bonuses, and the referred customer's reward.
  • No coupon-style codes at checkout. An affiliate code works only through a ?ref= link; typing it into the Promo Code box does nothing.
  • Nobody on staff is told when someone joins or a commission is earned. The affiliate isn't told when a commission is credited automatically or by Process Held Commissions; only a manual approve or reject emails them.
  • The customer's own link from the API (GET client/affiliate/me/link) is built on the engine's address, not the site's (tested). The account page builds its link from the site's own address, so use that, or the staff link in step 5.
  • The test call (affiliate/test/attribute-order) writes real referrals; there is no dry run.

Don'ts

  • Don't switch a program on, approve affiliates or release commissions without a yes.
  • Don't approve, process or complete a payout. A person does.
  • Don't make a flat program; use a percentage, or a flat override set with the call.
  • Don't rename a program that has affiliates.
  • Don't send part of payout, commission, attribution, fraud or terms in an update; send the whole part.
  • Don't reset a code because someone lost it; send it again (codes/send).
  • Don't promise a discount to the new customer through the program; give them a discount code.
  • Don't leave test referrals on a real affiliate.