On this page
A referral program is a small distributed-systems problem wearing a marketing hat. The flow sounds simple (someone shares a link, a friend signs up, the sharer earns a commission) but every step is a place to lose money or hand it out wrongly: a tampered link that credits the wrong affiliate, a signup attributed twice, a commission paid on a payment that later refunds. This page is the attribution-and-commission pattern that stays honest: the referral is carried in a signed cookie, claimed exactly once at signup, and turned into a commission only when a real, verified payment settles, with every decision made on the server.
The model has three moments. A click sets a signed ref cookie. A signup claims the attribution (first click wins, idempotent per user). A payment records a conversion and computes the commission. Keep all three server-side and atomic, and the program cannot be gamed by editing a URL or replaying a request.
Sign the referral, do not trust the URL
The ?ref= value in a link is attacker-editable. If you store it raw, anyone can credit themselves or burn a competitor's account. Wrap the affiliate id, a visitor id, and the first-click timestamp in a signed payload (an HMAC or signed JWT) and set that as the cookie. On signup you verify the signature before trusting any of it.
// On a ?ref= click: verify the affiliate exists, then set a SIGNED cookie.
const payload = { affiliate_id, visitor_id, first_click_at };
res.cookies.set(AFF_REF_COOKIE, await signAffRef(payload), {
httpOnly: true, sameSite: "lax", maxAge: 60 * 60 * 24 * 30, // 30-day window
});
// On signup: verifyAffRef(cookie) returns the payload only if the signature checks.The rules
Whenyou store a referral identifier from a URL
Dosign it (HMAC or signed JWT) and set it as an httpOnly cookie; verify the signature before trusting it. A raw ?ref= value is forgeable and lets anyone credit themselves.
const payload = await verifyAffRef(cookie); // null if tampered
if (!payload) return; // ignore forged/expired refsWhena referred user signs up
Doclaim the attribution once, idempotently, keyed on the new user id (first click wins). Do it in an atomic RPC so concurrent callbacks cannot double-attribute.
const payload = await verifyAffRef(refCookie);
if (payload) {
await admin.rpc("claim_affiliate_attribution", {
p_user_id: userId, p_affiliate_id: payload.affiliate_id,
p_first_click_at: payload.first_click_at,
}); // unique on user_id: a second call no-ops
}Whenattribution side effects run in an auth callback
Domake them best-effort and never block the redirect. A failed attribution is a bookkeeping problem; a blocked sign-in is a lost customer.
try { await claimAttribution(...); }
catch (e) { log.error("affiliate.claim_unexpected", e); } // swallow, continueWhena referred user makes a payment
Dorecord the conversion and compute the commission inside the payment webhook, AFTER the payment row commits, in the same idempotent path. Never credit a commission from the browser.
// In the webhook, once the payment is inserted (status paid):
const conv = await recordConversion({
client: admin, paymentId, userId, grossCentavos: amount, planId,
}); // returns { ok, commissionCents, tier } or { ok:false, reason }Whenyou compute commission amounts or tiers
Dodo it server-side from the verified gross amount and your own tier table, never from anything the client sent. The commission is derived from the trusted payment, not from a referral parameter.
-- In the RPC: tier and rate come from server config, gross from payments row.
-- commission_cents = round(gross_cents * rate_for_tier(affiliate_tier))Whena payment that earned a commission is refunded or charged back
Doreverse or void the conversion. Commission must track the real, settled money; a paid-out commission on refunded revenue is a direct loss.
// On refund webhook: mark the conversion reversed so payouts exclude it.
await admin.from("conversions").update({ status: "reversed" })
.eq("payment_id", paymentId);Whenyou expose affiliate dashboards, payouts, or assets
Dogate every affiliate route behind auth and ownership, and keep them out of the search index. An affiliate sees only their own numbers; these are private app pages, not content.
disallow: ["/affiliate/dashboard", "/affiliate/payouts", "/affiliate/conversions"],
// public marketing pages like /affiliate/terms stay crawlable.