How to Add a Referral Program to a Subscription App

Also in: German · Spanish

A referral program sounds like a weekend feature: give every user a code, reward them when a friend signs up. Then the questions start. How does the code survive an App Store install? Who gets paid when the friend only starts a free trial? What happens when the friend gets a refund three weeks later? Who sends the money?

This guide answers them in order. The first part is vendor-neutral: the seven building blocks a referral program for a subscription product needs on iOS, Android and the web, with the trade-offs at each step. The second part is a walkthrough with AppThunder Referrals as the example implementation, using its real endpoints and code.

Note: third-party facts are as of October 2026 and linked to their source. This is an engineering guide, not legal or tax advice. If your program runs inside an iOS app, also read our guide to the App Store rules for referral programs – nobody can guarantee an App Review outcome.

The seven building blocks

Block Question it answers Typical mistake
1. Codes Who is the referrer? Generating codes in the app
2. Capture How does the code reach the new user? Relying on a link surviving an app install
3. Attribution Which payment belongs to which code? Counting installs or signups instead of payments
4. Commission rules How much, for how long? No decision on gross vs. net or on currency
5. Hold and refunds When is a commission final? Paying out before refunds arrive
6. Payouts Who sends the money? No record of what a payout covered
7. Fraud controls What must not count? No self-referral check

1. Codes: one per user, created on the server

Each referrer gets one code, stored with their user record. Your server creates it the first time it's needed and returns the stored code afterwards. Never generate codes in the client.

  • Use an unambiguous alphabet. People type codes from a screenshot. Leave out 0/O and 1/I.
  • Tie the code to the user ID your billing system knows. If your subscription system identifies users by an app user ID, use the same ID for the referrer – otherwise you can't detect self-referrals.
  • Make codes deactivatable. A partner leaves, a code leaks to a coupon site: switch it off without deleting history.

2. Capture: how the code reaches the new user

Channel How the code can arrive Deterministic?
Web ?ref=CODE in the link, stored in the browser until signup Yes, same device and browser
iOS, app installed Universal link or URL scheme carrying the code Yes
iOS, fresh install The new user types the code Yes
Android, fresh install Typed code, or the Play Install Referrer Yes
Any Matching IP, device model and timing ("fingerprinting") No – a guess

On iOS, a link can't carry a code through an App Store install. Universal links only open an app that's already installed. App Store Connect campaign links (ct= token) feed aggregated App Analytics with minimum thresholds; they don't tell your app who invited this user. On Android, the Play Install Referrer API returns the referrer URL of the install, so a code in the Play Store link can reach the first launch.

The pattern that works everywhere: the share message contains the code itself, not only a link, and onboarding has a "Have a referral code?" field. Validate the code against your server before showing "applied".

Fingerprinting fills the gap on paper, but it's probabilistic – commissions paid on it are real money paid on guesses, and it brings privacy obligations a code field doesn't.

Also decide what happens when someone arrives with two different codes. First touch (the first code wins) is easy to explain and hard for coupon sites to hijack at the last moment. Apply the same rule in the browser and on the server.

3. Attribution: tie the code to the payment

In a subscription business the conversion is neither the install nor the signup – it's the payment: the first one and, depending on your rules, the renewals. So the code has to sit where your billing system echoes it back when money arrives:

  • In-app subscriptions: subscription SDKs such as RevenueCat let you attach custom attributes to the subscriber; RevenueCat documents that they're available "in webhooks".
  • Stripe: put the code into the subscription's metadata when you create the Checkout Session (subscription_data), so every invoice of that subscription carries it.
  • Anything else: your backend records "user X came with code Y" at signup and reports each payment.

Three rules keep it clean:

  1. First attribution wins. A second code doesn't move an attributed user.
  2. Follow identity changes. Anonymous IDs get merged, subscriptions get transferred – RevenueCat sends a TRANSFER event for this. The referral has to move too.
  3. Ignore test purchases. Sandbox and test-mode events never create commissions.

4. Commission rules

Rule Example Good for
Percent, recurring, unlimited 20 % of every payment Creators and partners who keep promoting
Percent, recurring, limited 20 % for the first 12 months Most subscription apps – rewards retention without a lifetime liability
Fixed per conversion $10 when the friend first pays User-to-user programs

Two decisions people skip:

  • Gross or net? The stores keep a commission, and on the web there's VAT. Decide whether the percentage applies to what the customer paid or what you received, and put it in your program terms.
  • One ledger currency. Customers pay in many currencies; a referrer's balance needs one. RevenueCat's webhook, for example, sends price as the "USD price of the transaction".

A free trial is a referral, not revenue: mark it as attributed and pay nothing until the first real payment.

5. Hold period and refunds

Refunds arrive after the purchase. App Store customers request them later via reportaproblem.apple.com; for card payments, Stripe notes that networks normally allow disputes within 120 days. Pay commissions immediately and you'll eventually pay some on money you no longer have.

The answer is a hold period: every commission starts as pending, becomes approved after a fixed number of days, and only approved commissions are paid out. A refund during the hold voids the commission. A short hold pays referrers faster, a long one catches more refunds; for monthly subscriptions, 30 days – roughly until the first renewal – is a reasonable start. Two details:

  • Void per transaction, not per subscription. Refunding March must not void January and February.
  • A cancellation isn't a refund. A user who stops renewing keeps the months they paid for, so commissions already earned stay.

6. Payouts

Option What it means
Pay out yourself Export who is owed what, pay via PayPal, Wise or bank transfer, mark as paid
Platform that pays referrers Less manual work; you depend on its onboarding of every referrer
In-app rewards Free months or credits – on iOS through Apple's mechanisms (below)

For a small program, a monthly export plus one batch payment is enough. Record which commissions each payout covered, so a late refund can be traced, and check the tax side of paying referrers in your country before the first payout.

7. Fraud controls

  • Self-referrals blocked: same user ID, an alias of it, or the same email on both sides
  • Commissions only on real payments, never on installs or signups
  • Sandbox and test-mode events ignored
  • Hold before payout; refunds void commissions
  • First attribution wins
  • Codes and secret API keys only on the server

What may the invitee get?

Rewarding the sender is the core of a referral program. Rewarding the receiver is where iOS gets delicate: a homemade code that unlocks premium access conflicts with guideline 3.1.1 of Apple's App Review Guidelines – "Apps may not use their own mechanisms to unlock content or functionality". Our App Store referral rules guide covers the details. For a discount, use the stores' tools:

  • iOS: subscription offer codes – free or discounted periods, redeemable via URL, in your app or in App Store account settings.
  • Android: Google Play promo codes, which for subscriptions give a free trial of 3 to 90 days.

Both are discounts, not attribution: they don't tell you who invited someone. You still need your own referral code for the commission.

Walkthrough: building it with AppThunder Referrals

AppThunder Referrals costs €19 per month flat (net, plus VAT), with no revenue share. Creating a program is free; a card is needed before the first referral code is created. Each program has a public key (pk_aff_…, safe in apps and web pages – it can only validate a code) and a secret key (sk_aff_…, backend only). The examples use pk_aff_xxx and sk_aff_xxx; the base URL is in your dashboard.

Step 1: Set the rules

Create a program in the dashboard and choose the commission rule – a percentage of every payment (for all months or a set number) or a fixed amount per conversion – and the hold (30 days by default). Commissions are computed on the price excluding tax, before store fees, in a USD ledger; other currencies are converted at the ECB reference rate of the payment date.

Step 2: Create each user's code on your backend

POST https://<project>.functions.supabase.co/aff-ref-api/codes
Authorization: Bearer sk_aff_xxx
Content-Type: application/json

{ "external_user_id": "<your user id>" }
→ { "code": "K7QM4XP" }

Repeat calls for the same user return the same code; without a card on file the answer is 402 payment_required. For in-app subscriptions, use the user ID your subscription SDK knows – that's what the self-referral check compares.

Step 3 (iOS): Capture the code and attach it to the subscriber

The Swift template is a copy-paste file, not a package. Purchases is the RevenueCat SDK; the template uses RevenueCat's standard custom attributes and webhook, which any developer can configure – no partnership or official integration. The essentials:

import UIKit
// + your subscription SDK's import (provides Purchases)

enum AppThunderRef {
    static let apiBase = URL(string: "API_BASE")!  // dashboard

    /// Checks a typed code before "applied" UI. Public key only.
    static func validateCode(_ code: String, done: @escaping (Bool) -> Void) {
        var url = URLComponents(url: apiBase.appendingPathComponent("codes/\(code)"), resolvingAgainstBaseURL: false)!
        url.queryItems = [URLQueryItem(name: "public_key", value: "pk_aff_xxx")]
        URLSession.shared.dataTask(with: url.url!) { data, _, _ in
            let json = data.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] }
            done(json?["valid"] as? Bool ?? false)
        }.resume()
    }

    /// Attaches the code to the subscriber as the attribute `at_ref`.
    static func applyCode(_ code: String) {
        Purchases.shared.attribution.setAttributes(["at_ref": code])
    }
}

Call validateCode from the onboarding field (done runs on a background queue – hop to the main queue for UI), show "applied", then applyCode. The full template adds share, which opens the share sheet with Use my code K7QM4XP: https://yourapp.com/?ref=K7QM4XP, so the code survives the install. The Kotlin, React Native and Flutter templates do the same. No secret key appears in app code – anything shipped in an app can be extracted.

Step 4: Connect the purchase events

  • Subscription webhook (in-app). Paste the webhook URL from your dashboard into your subscription platform's webhook settings, plus the authorization header value shown once in the dashboard. Initial purchases, renewals and non-renewing purchases create commissions; a refund (cancellation reason CUSTOMER_SUPPORT) voids that transaction's commission, a partial refund reduces it; an ordinary cancellation doesn't; sandbox events are ignored; a TRANSFER moves the referral.
  • Stripe (web). Add a webhook endpoint in your Stripe Dashboard for invoice.paid, invoice_payment.paid, charge.refunded, charge.dispute.created and charge.dispute.closed, paste its signing secret into your program settings, and put the code into the subscription metadata as at_ref (Step 5). A dispute suspends the commission until it's decided; partial refunds reduce it.
  • REST API (any other billing). Register the referral at signup with POST /referrals (code, referee_external_id) – the referrer's own ID or email gets 422 self_referral_blocked – then report each payment:
POST https://<project>.functions.supabase.co/aff-ref-api/events
Authorization: Bearer sk_aff_xxx
Content-Type: application/json

{ "external_user_id": "<your user id>", "amount": 29.00, "currency": "usd",
  "provider_event_id": "inv_1042", "group_ref": "inv_1042" }

provider_event_id makes the call idempotent; amount is converted to USD at the ECB reference rate if you send another currency. A refund is the same call with a negative amount, a new provider_event_id and the sale's group_ref (a sale's group_ref defaults to its own provider_event_id). Refunding part of the amount reduces the commission by that share.

Step 5 (web): Snippet and checkout

The dashboard generates a <script> snippet for every page a referral link can land on. It reads ?ref=, keeps the first code in localStorage for 90 days on that device, and exposes window.AppThunderRef. It holds only the public key and can't create a commission:

// browser
const code = window.AppThunderRef.getCode();   // null if none
// send `code` to your server with the signup form, as referredBy

// your server (Node, Stripe Billing)
const session = await stripe.checkout.sessions.create({
  mode: 'subscription', /* line_items, success_url … */
  subscription_data: { metadata: { at_ref: req.body.referredBy } },
});

On the first paid invoice with a valid code, the Stripe webhook creates the referral and the commission; renewals follow through the same webhook. If your site runs inside an app built with AppThunder, the snippet works unchanged in the app's web view.

Step 6: Show the numbers, pay out

GET /members/{external_user_id}/stats returns { "referrals": n, "earned_usd": x }. It requires the secret key, so call it from your server for the signed-in user. After the hold, export approved commissions (PayPal Mass Pay, Wise batch or CSV), pay your referrers and mark the batch as paid.

Checklist before you launch

  • One code per user, created on the server
  • Code field in onboarding; the share message contains the code
  • Code attached to the subscriber or Stripe subscription, not only your signup record
  • Commission rule, currency and gross/net in your program terms
  • Hold set; refunds void per transaction
  • Secret key only on your backend
  • Invitee discounts via Apple offer codes or Google Play promo codes
  • Program described in the Notes for Review in App Store Connect

Where AppThunder Referrals fits

AppThunder Referrals covers blocks 1 to 5 and the bookkeeping of 6: server-generated codes, copy-paste templates for Swift, Kotlin, React Native, Flutter and web, attribution via subscription webhook, Stripe webhook or REST API, commission rules, a configurable hold, refunds voiding commissions, self-referral blocking and payout exports. Attribution is deterministic only – a referral counts when a code actually arrives, without device fingerprinting or probabilistic matching. €19 per month flat, no revenue share, cancel monthly.

What it doesn't do:

  • Move money. AppThunder never holds or sends funds; you pay referrers from the export, and taxes stay yours.
  • Deferred deep links. On a fresh iOS install the new user types the code – the consequence of not fingerprinting.
  • Ship an SDK. You copy a template and own that code.
  • Claw back paid commissions. Refunds and lost disputes void or reduce commissions until you mark them paid; after that, settling with the referrer is up to you.
  • Reward invitees. Discounts for the friend run through Apple offer codes or Google Play promo codes, set up in the store consoles.

FAQ

Can a referral link survive an App Store install? Not deterministically. Universal links only open an installed app, and campaign links only feed aggregated analytics. Put the code in the share message and let the new user type it.

Should I pay commission on a free trial? Count the trial as an attributed referral, but pay only when the first real payment arrives.

How long should the hold period be? Long enough to cover the window in which most of your refunds arrive. For monthly subscriptions, 30 days is a reasonable start.

Can I put the secret key in my app if I obfuscate it? No. Anything shipped in an app can be extracted. The app gets the public key, which validates codes and nothing else.

Do I need RevenueCat to use AppThunder Referrals? No. The mobile templates use RevenueCat's SDK for the subscriber attribute, but any backend can use the REST API instead, and web products can use the Stripe webhook.