The Referral System Hiding in App Store Connect
How to build a real affiliate program on top of Apple’s offer codes, without paying a third party a cent
Most iOS app founders I talk to assume that building a referral system is a six-figure problem. They look at Branch, AppsFlyer, Tapfiliate, Rewardful. They quote out something like $500 to $5,000 a month. Then they decide referrals are a “later” problem and ship without one.
Here’s the thing nobody tells you: Apple already gives you the primitives. You can generate one-time promo codes through the App Store Connect API, hand them out programmatically, and detect when each one is redeemed. With RevenueCat (or your own receipt validation) on the back end, you have everything you need to credit a referrer the moment their friend converts.
I built this at Alma in about a week. This post is the missing manual.
What Apple Actually Gives You
Inside App Store Connect, every auto-renewable subscription can have a subscription offer, and every offer can have one-time use codes. Each code is a unique string that unlocks the offer for one person. You’ve probably seen them used in beta testing: “here’s a free month, paste this in Settings.”
What’s less well known is that the App Store Connect API will generate these in batches for you. You can request 25,000 codes in a single API call. They come back as a CSV-like response with a unique string per row, and Apple tracks redemption state on their side.
So the building blocks are:
Generate codes programmatically through the App Store Connect API
Hand a code to a user via your own backend
Detect redemption through a RevenueCat webhook (or App Store Server Notifications if you’re going direct)
Map the redeemed code back to the referrer and reward them
That’s it. No third-party SDK in your binary. No second analytics pipeline. The whole loop lives between Apple, your database, and your subscription provider.
The Pool Pattern
The trick that makes this work at scale is treating offer codes like a resource pool, not a per-user generation request.
Generating codes through the App Store Connect API is not instant. It’s a background job on Apple’s side, and the codes don’t appear immediately. If the first time a user taps “Share with a friend” is also the first time you call Apple’s API, you’ve just made your share button a 30-second loading spinner.
So we keep a pool. A Postgres table with one row per code:
one_time_code_pool
code TEXT
status ENUM(available, pending, used, expired)
assigned_to_user UUID NULL
expires_at TIMESTAMP
A scheduled job watches the count of available rows. When it dips below a threshold, it calls the App Store Connect API and inserts the new batch. Codes flow through the lifecycle:
available: minted by Apple, sitting in the poolpending: assigned to a referrer, waiting for someone to redeemused: redeemed by a friend, attribution completeexpired: never used within Apple’s expiry window
When a user calls GET /referral/my-code, the backend grabs the next available row, marks it pending, stamps the user’s ID on it, and returns it. The user always gets an answer in under 100ms.
Authenticating to App Store Connect
The fiddly part is the JWT. The App Store Connect API uses a signed token, and the signing key format is a footgun.
Inside App Store Connect, under Users and Access > Integrations > App Store Connect API, you create a key. Apple gives you a .p8 private key file and three identifiers: a Key ID, an Issuer ID, and a Team ID. You only get to download the key once. Lose it and you start over.
The key looks like this:
-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg...
-----END PRIVATE KEY-----
In environment variables, the newlines often get stripped, which is what makes this annoying. Your JWT signing code needs to handle both the full PEM (with headers) and the raw body (without). We normalize on read, then sign with ES256:
def generate_app_store_connect_jwt():
headers = {"alg": "ES256", "kid": APPLE_KEY_ID, "typ": "JWT"}
payload = {
"iss": APPLE_ISSUER_ID,
"iat": int(time.time()),
"exp": int(time.time()) + 1200, # 20 minutes max
"aud": "appstoreconnect-v1",
}
return jwt.encode(payload, normalized_private_key, algorithm="ES256", headers=headers)
Tokens are valid for at most 20 minutes. We mint a new one per request rather than caching, which is fine because the cost is microseconds.
With the JWT in hand, generating a code batch is a single POST to /v1/subscriptionOfferCodes with a relationship to your subscription, the batch name, the number of codes, the expiry, and the activation date. Apple processes it asynchronously. A second poll a few minutes later returns the actual code strings.
The Redemption Path
This is where the architecture gets opinionated. Apple gives you two ways to know a code was redeemed, and you need both.
Path one: the RevenueCat webhook. When a friend pastes the code into the App Store offer redemption sheet and completes the purchase, RevenueCat fires an INITIAL_PURCHASE event to your backend. The payload includes an offer_code field. You match that string against the pending rows in one_time_code_pool, find the referrer, mark the row used, and credit the referrer.
Path two: the iOS confirm endpoint. SwiftUI ships a built-in modifier called .offerCodeRedemption(isPresented:onCompletion:). It opens Apple’s native redemption sheet. When it returns successfully, your app immediately calls POST /referral/confirm-redemption?referral_code=ABC123 so the backend can credit the referrer right away.
Why both? Because the RevenueCat webhook sometimes arrives without offer_code populated, or arrives minutes late, or arrives never (we’ve seen all three). And the iOS confirm path is unreliable too: the app might be backgrounded, the network might drop, the user might force-quit. You write idempotent code that handles either path arriving first, and you stop worrying about it.
The idempotency rule is one row in referral_completions per (referrer, referee) pair. If both paths fire, one wins, the other is a no-op.
Crediting the Referrer
This is the only part where you genuinely need a subscription provider with a “promotional” or “gifted” entitlement concept. RevenueCat calls it a promotional entitlement and exposes it through POST /subscribers/{id}/entitlements/{ent}/promotional. You hit that endpoint with a duration (we use 30 days per successful referral, stackable), and the user immediately gets premium features even though they never paid.
If you’re not using RevenueCat, the equivalent is maintaining a granted_premium_until column on your user table and checking it alongside the App Store receipt. Slightly more code, but the same shape.
The thing to internalize is: the reward doesn’t have to be subscription credit. Once you have a reliable signal that “user A successfully referred user B,” you can reward however you want. Cash via Tremendous. Account credits. Entries in a giveaway. A T-shirt. The hard part was attribution, not payout.
The Whole Loop in One Diagram
Referrer taps "Share"
→ GET /referral/my-code
→ backend pulls next available code from pool, marks it pending
→ returns "Use code XYZ123 for a free month: apps.apple.com/redeem?code=XYZ123"
Friend taps the share message
→ opens App Store offer redemption sheet (native iOS)
→ completes purchase
Two things happen, in some order:
→ RevenueCat sends INITIAL_PURCHASE webhook with offer_code=XYZ123
→ iOS app calls POST /referral/confirm-redemption?referral_code=XYZ123
Backend (idempotent):
→ marks pool row 'used'
→ inserts row in referral_completions
→ grants referrer 30 days of promo access via RevenueCat
→ optionally pays referrer cash via Tremendous, sends a push, etc.
What This Unlocks
When the cost of building a referral program drops from “evaluate three vendors and sign a $20k contract” to “two days of backend work,” the calculus changes. You stop debating whether to do referrals. You start iterating on the offer.
A few things we’ve tried since shipping this:
Tiered rewards. Refer one friend, get a month free. Refer five, get a year. The pool doesn’t care.
Influencer codes. A creator gets their own batch of 100 codes branded for their audience. Same infrastructure, different UI for distribution.
Win-back offers. A churned user gets a one-time code in an email. Same pool table, different assigned_to semantics.
Affiliate cash payouts. For a partnership with a fitness coach, every redemption of their code triggered a $15 payment to them via Tremendous. We had the attribution. Tremendous had the rails. The whole thing was a 200-line script.
None of this required a new vendor. It all came out of the same one_time_code_pool and referral_completions tables.
What Still Requires Care
Being honest about the rough edges:
Apple’s processing latency. Code batches don’t appear instantly. If your pool empties and you have to wait for Apple, your referral feature returns a 503. Keep your refill threshold generous and run the cron daily, not weekly.
Webhook authentication. RevenueCat sends a shared secret in the auth header. If you don’t validate it, anyone on the internet can forge a successful referral and grant themselves premium. We reject any webhook missing the expected header value. This sounds obvious. It is not always done.
Self-referral. You’d be amazed how many people try to redeem their own code. The check is one line (if referrer_id == referee_id: reject) but it has to be there.
Entitlement identifier drift. We had a bug for a week where the webhook path granted entitlement premium and the iOS confirm path granted entitlement alma_premium_promo. Both reported success. Only one actually unlocked the UX. Pick one identifier, use it everywhere, and write a test.
Admin endpoints. It’s tempting to expose GET /referral/admin/stats to see how the program is performing. If it’s on a public API host, add auth. We left ours unauthenticated for one afternoon and it was indexed by a crawler before sundown.
The Stack
For anyone building this:
App Store Connect API: Code generation, JWT auth with ES256,
/v1/subscriptionOfferCodesendpointPostgres:
one_time_code_poolandreferral_completionstables, plus an Alembic migrationFastAPI: Three endpoints (
my-code,stats,confirm-redemption) plus the RevenueCat webhook handlerRevenueCat: Webhook receiver and promotional entitlement grants via their v1 API
SwiftUI:
.offerCodeRedemption(isPresented:onCompletion:)modifier for the native redemption sheetBackground worker: A cron job that monitors pool depth and refills via the ASC API
Total backend code is around 800 lines. Total iOS code is around 200. We use the standard subscription stack we already had, plus one new table and one new router.
The Takeaway
The real insight isn’t that Apple has a referral API. It’s that the primitives have been sitting in App Store Connect for years and almost nobody assembles them.
If you’re already using a subscription product, you’re already authenticating to Apple, validating receipts, and listening for webhook events. Adding a referral loop on top is a weekend, not a quarter. The codes are free. The infrastructure is yours. The only thing the third-party vendors are really selling is the assembly, and the assembly is a long blog post.
We shipped Alma’s referral program with this stack three months ago. Every code redeemed has been attributed. The pool refills itself. The referrer gets credit before their friend has even logged their first meal.
If you’re sitting on a subscription app and waiting for “the right time” to build referrals, this is your sign. Apple’s already done the hard part.
Curious about what we’re building? Alma is an AI nutrition coach that actually remembers you. Free to start.

