# Referrals

A referral link is an invitation your users share with friends. When a friend installs and signs up, WarpLink credits the install to the link and sends your backend a signed webhook. You grant the reward in your own system. We do the matching and keep the record. The reward and its rules stay with you.

This guide walks the whole path: link, install, claim, webhook, report.

## How a referral works

1. **You create a referral link.** It is an ordinary WarpLink link with referral turned on.
2. **A friend taps it.** An iPhone visitor without your app sees a short invitation page that copies an invitation, then goes to the App Store. An Android visitor goes to Google Play with the invitation inside the install referrer.
3. **The app opens for the first time.** On Android the SDK reads the invitation by itself. On iPhone the person taps a paste button and the SDK reads the invitation they paste.
4. **The friend signs up.** Your app calls `claimReferral` with your own id for the new user, or your backend calls the claims API.
5. **We record the referral and send `referral.claimed`.** Your backend grants the reward.

## Before you start

- Register the app with its App Store URL for iOS, and its package name and signing fingerprints for Android. [Find your app identifiers](https://warplink.app/docs/guides/find-app-identifiers) shows where each value lives.
- Update the SDK to 1.2.0 or later. Builds on 1.1.x keep working for links and installs, but they cannot read an invitation.
- Open the app in the dashboard, go to **Referral handoff**, and choose **Setup**. The checks show what each platform still needs. When iOS is ready, turn on **Offer referral handoff on iOS**.
- Choose the **Referral credit window**: 24 hours, 7 days (the default), or 30 days from the tap. A claim inside the window is credited. A later claim is recorded and flagged. A change applies to taps after you save it.

Turning on the iOS handoff needs an App Store URL on the app. Without it the request answers `409 RECOVERY_PREREQUISITES_MISSING`, and `details.missing` lists `app_store_url`. An iOS build on SDK 1.2.0 or later seen in the last 30 days is not required to turn it on: without one, the dashboard shows a warning, and new iPhone installs from referral links cannot read the invitation and are not credited. Android needs no switch, only a signing fingerprint on the app. The Setup Check ids `ios_recovery_store_url`, `ios_recovery_sdk` and `android_recovery_fingerprint` report each requirement, and `referral_readiness` on `GET /v1/apps/{id}` returns the same answer.

While the iOS handoff is off, an iPhone referral can still be credited as an estimated match from device signals, and a later exact claim from the same person upgrades it. See [Exact and estimated matches](#exact-and-estimated).

## Create referral links

Any link that belongs to an app can be a referral link. A link with no app cannot, and the request answers `400 VALIDATION_ERROR`.

**In the dashboard:** open a link and turn on **Referral link**. In the links list, select links and choose **Make referral links** to convert many at once, and filter the list to referral links only.

**Over REST:** send `is_referral` when you create a link, or change it later with a `PATCH`. The key needs `links:write`.

```bash
curl -X POST https://api.warplink.app/v1/links \
  -H "Authorization: Bearer wl_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "app_id": "YOUR_APP_ID",
    "slug": "invite-ana",
    "destination_url": "https://example.com/invite",
    "is_referral": true
  }'
```

Convert an existing link the same way:

```bash
curl -X PATCH https://api.warplink.app/v1/links/LINK_ID \
  -H "Authorization: Bearer wl_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "is_referral": true }'
```

The link returns `is_referral` and `referral_since`, the time it became a referral link. A tap before that time is not a referral. `GET /v1/links?is_referral=true` lists only referral links.

**Over MCP:** `create_link`, `update_link` and `list_links` take `is_referral`, so an assistant can mint an invitation link for each new user. The [AI access](https://warplink.app/docs/ai-access) page lists the referral tools.

Create the link when your user asks to invite someone, and **store the link id with that user**. When the webhook arrives, `link.id` tells you whose invitation earned the credit.

### The invitation page

An iPhone visitor without your app lands on a short page before the App Store. Set its look and wording under **Referral handoff**, then **Page**. The same page is available as `GET` and `PUT /v1/apps/{id}/referral-page` and through the MCP tools `get_referral_page` and `update_referral_page`. Ordinary links never show it.

## The paste step on iPhone

On iPhone the install cannot carry the invitation, so the first launch asks for it. Put the paste button on the screen where a new person first sees your app.

```swift
import SwiftUI
import WarpLink

struct WelcomeView: View {
    @State private var message: String?

    var body: some View {
        VStack(spacing: 16) {
            Text("Did a friend invite you?")
            WarpLinkPasteButton { result in
                switch result {
                case .success(let link):
                    // nil means nothing was confirmed.
                    if link != nil { message = "Invitation applied." }
                case .failure(let error):
                    // The message says how to open the invitation again.
                    message = error.localizedDescription
                }
            }
            if let message { Text(message) }
            Button("Skip") { continueOnboarding() }
        }
    }
}
```

```swift
import UIKit
import WarpLink

final class WelcomeViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let paste = WarpLinkUIPasteButton { [weak self] result in
            switch result {
            case .success(let link):
                if link != nil { self?.showApplied() }
            case .failure(let error):
                self?.showMessage(error.localizedDescription)
            }
        }
        view.addSubview(paste)
    }
}
```

```ts
import { WarpLink } from '@warplink/react-native';

// The person pastes into a field on your onboarding screen.
async function onInvitePasted(text: string) {
  try {
    const link = await WarpLink.redeemInvitation(text);
    if (link) showApplied();   // null means nothing was confirmed
  } catch (error) {
    // The message says how to open the invitation again.
    showMessage((error as Error).message);
  }
}
```

```dart
import 'package:warplink_flutter/warplink_flutter.dart';

// The person pastes into a field on your onboarding screen.
Future<void> onInvitePasted(String pastedText) async {
  try {
    final link = await WarpLink.redeemInvitation(pastedText);
    if (link != null) showApplied(); // null means nothing was confirmed
  } on WarpLinkServerException catch (error) {
    // The message says how to open the invitation again.
    showMessage(error.message);
  }
}
```

The SDK never reads the clipboard. The system paste button asks the person to allow each paste, and the SDK sees only the text that button hands over. React Native and Flutter take the pasted text from a field you provide.

### Keep onboarding open

The paste step is an offer, not a gate. A person can skip it, the copy can fail, and the clipboard can hold something else by the time they paste. In each case `redeemInvitation` returns no match and onboarding continues, and the paste step stays available on the screen if the person comes back to it. Show a referral as applied only when you get a link back. Without one, nothing is confirmed, so do not tell the person a referral was applied.

### Expired or invalid invitations

An invitation that has expired, was changed, or belongs to another app is refused with `400`. The error message tells the person to open the invitation link again or ask the friend for a new one, and your app can show it as it is. An invitation also expires when its link is paused or ended. See `RECOVERY_TOKEN_EXPIRED` and `RECOVERY_TOKEN_INVALID` on the [errors](https://warplink.app/docs/errors) page.

When the server has not yet received a just-issued invitation, it answers `503 RECOVERY_TOKEN_PENDING` with a `Retry-After` header. The SDK keeps the invitation on the device and tries again on later launches.

In React Native and Flutter, an invitation delivered on a later launch reaches your app through `getAttributionResult()` and `claimReferral`, not through the `onLink` callback. Call `claimReferral` after sign-up as usual and it picks the invitation up.

## Android

There is nothing to paste. Google Play hands the install referrer to the SDK at first launch, the invitation rides inside it, and the SDK matches it with no screen of yours. Register the app's signing fingerprint so the Setup Check can confirm the path.

## Claim the install [#installer-id]

After the friend signs up, tell WarpLink which of your users they are. You send an **installer id**: your own id for the new user.

Make the installer id a dedicated random id, such as a UUID stored in its own column on your users table. The rules:

- It is opaque, up to 128 characters, and one per person.
- It is a separate id, not your primary key, an email address, a phone number, or any other personal data.
- We store it as written and do not check what it looks like.

The first valid claim for an app and installer id wins. Sending the same claim again is safe.

```swift
WarpLink.claimReferral(installerId: user.warplinkId) { result in
    guard case .success(let claim) = result else { return }
    switch claim.status {
    case .claimed, .flagged:
        break // recorded. Your webhook grants the reward.
    case .ignored:
        break // already credited: see claim.duplicateOf
    case .none:
        break // no referral link brought this install
    }
}
```

```kotlin
WarpLink.claimReferral(user.warplinkId) { result ->
    result.onSuccess { claim ->
        when (claim.status) {
            ReferralClaimStatus.CLAIMED, ReferralClaimStatus.FLAGGED -> Unit // recorded
            ReferralClaimStatus.IGNORED -> Unit // already credited: see claim.duplicateOf
            ReferralClaimStatus.NONE -> Unit // no referral link brought this install
        }
    }
}
```

```ts
const claim = await WarpLink.claimReferral(user.warplinkId);
switch (claim.status) {
  case 'claimed':
  case 'flagged':
    break; // recorded. Your webhook grants the reward.
  case 'ignored':
    break; // already credited: see claim.duplicateOf
  case 'none':
    break; // no referral link brought this install
}
```

```dart
final claim = await WarpLink.claimReferral(user.warplinkId);
switch (claim.status) {
  case ReferralClaimStatus.claimed:
  case ReferralClaimStatus.flagged:
    break; // recorded. Your webhook grants the reward.
  case ReferralClaimStatus.ignored:
    break; // already credited: see claim.duplicateOf
  case ReferralClaimStatus.none:
    break; // no referral link brought this install
}
```

```bash
curl -X POST https://api.warplink.app/v1/referrals/claims \
  -H "Authorization: Bearer wl_live_YOUR_API_KEY" \
  -H "Idempotency-Key: signup-8f21" \
  -H "Content-Type: application/json" \
  -d '{
    "app_id": "YOUR_APP_ID",
    "installer_id": "d1c2f9a0-7b43-4f0e-a6d5-3c2b8e1f7a90",
    "install_id": "INSTALL_ID_FROM_THE_APP"
  }'
```

A `none` result is an ordinary answer, not an error. The call fails only when the server cannot be reached, the key is refused, or the installer id is empty or longer than 128 characters.

### Claim results

| `status` | Meaning |
|----------|---------|
| `claimed` | The install is credited to the referral link, inside the credit window |
| `flagged` | The claim came after the window closed. We record it with the flag `outside_window` so you can decide |
| `ignored` | This installer already holds a credit. The answer carries `duplicate_of`, the referral that holds it |
| `none` | No referral link brought this install. Nothing is recorded |

A tap on a device that already has the app is not a referral, unless **Count people who already have the app** is on for the app. It is off by default. An install from an ordinary link, or a tap from before the link became a referral link, answers `none`. A claim that names a `link_id` of an ordinary link answers `409 LINK_NOT_REFERRAL` and records nothing.

### Claim from the SDK or from your backend

Both record the same referral. A claim from the SDK needs no server work: the SDK sends the evidence it holds and the answer comes back to the app. An SDK key can call this route, and the SDK routes are limited to 60 requests a minute per key per client IP address.

For rewards of value, claim from your backend. Your backend holds your rules and your records, so it is the right place to decide when a sign-up has earned a reward. Send your server the `attributionId` from the SDK's attribution result together with the new user's installer id, then call `POST /v1/referrals/claims` with an API key that has `referrals:write`, passing the id as `install_id`. You can also send a `recovery_token` or a `link_id` in its place.

## Exact and estimated matches [#exact-and-estimated]

Every referral records how we matched it.

| Match | `match.method` | `exact` | Comes from |
|-------|----------------|---------|------------|
| Invitation on iPhone | `install_token` | true | The invitation the person pasted |
| Install referrer on Android | `android_referrer` | true | The invitation inside the Play referrer |
| Link open | `link_open` | true | A tap on a device that already had the app, when existing users count |
| Estimated match | `probabilistic` | false | Device signals, with a `confidence` |

An estimated match is iPhone only. It applies while the iOS handoff is off and probabilistic matching is on for the app. It goes out as a `referral.claimed` event with `exact` set to `false` and a confidence.

If the same installer later claims with an exact match, for example after they update to 1.2.0 and paste the invitation, the referral keeps its id and moves to the exact link. We send `referral.updated` with `previous` showing what it replaced. An exact claim is never replaced by an estimate. Use `exact` and `evidence_class` (`exact` or `estimated`; an install matched by its link id alone never earns referral credit) to decide how a referral counts in your rules.

## Receive the webhook

Add an endpoint under **Referral handoff**, then **Webhook**, and verify each event with its signature. [Receive referral webhooks](https://warplink.app/docs/guides/webhooks) has the payloads, signature code for Node.js and Python, retries, and resends.

## Test on your own phone

1. Create a referral link and turn on the app's handoff.
2. Delete the app from the phone, so the tap counts as a first install.
3. Open the link on the phone. An iPhone shows the invitation page, then the App Store or a TestFlight build.
4. Install, open the app, and tap the paste button.
5. Sign up as a new test user with a new installer id, and claim.
6. Open **Referral handoff**, then **Referrals**, for the new row, and confirm your endpoint received the event.

The first valid claim per app and installer id wins, so use a fresh installer id for each run. Delete a test referral with `DELETE /v1/referrals/{id}` or the MCP tool `delete_referral` to start over. **Send test** on the webhook tab checks your endpoint without a phone.

## The Referrals report

The report lives at `/referrals` in the dashboard and in each app's **Referral handoff** tab. It lists referrals newest first, with the link, the installer id, the match, and the webhook result. The same list is `GET /v1/referrals` and the MCP tool `list_referrals`, and `format=csv` exports up to 10,000 rows.

| Status | Meaning |
|--------|---------|
| `claimed` | Recorded. No webhook delivery has finished yet |
| `delivered` | Your endpoint answered `2xx` |
| `retrying` | A delivery failed and another attempt is scheduled |
| `failed` | Every attempt failed. Resend it after you fix the endpoint |
| `flagged` | Claimed after the credit window |
| `duplicate` | A later claim for an installer that already held a credit. It points at the first |

Open a referral to see its claims, the webhook events with the payloads we sent, and every delivery attempt. When an exact claim replaced an estimate, both claims and both events are listed.

## Troubleshooting

**A claim answers `none` and you expected a credit.** Check the link first: it must be a referral link, and the tap must be after `referral_since`. Then check the window, and whether the person already had the app, which counts only when **Count people who already have the app** is on.

**`409 LINK_NOT_REFERRAL`.** The `link_id` names an ordinary link. Turn on `is_referral` for it, or leave out the `link_id` and let the evidence decide.

**`403 SDK_KEY_ROUTE_FORBIDDEN`.** An SDK key reached a route other than the four SDK routes. Use an API key with `referrals:read` or `referrals:write` for the report and webhook routes.

**`RECOVERY_TOKEN_EXPIRED` or `RECOVERY_TOKEN_INVALID`.** `RECOVERY_TOKEN_EXPIRED` means the invitation ran out or its link is paused or ended. `RECOVERY_TOKEN_INVALID` means the text was changed, is not complete, or belongs to another app. Ask the friend to open the link again, or ask the inviter for a new one.

**The iOS switch will not turn on.** Setup lists what is missing. Add the App Store URL, or ship a build on SDK 1.2.0 and open it once so we see it.

## Frequently asked questions

### Is a referral match exact?

Yes, when the invitation reaches the app. An invitation pasted on iPhone matches as `install_token`, and an invitation inside the Play referrer on Android matches as `android_referrer`. Both are exact. An iPhone install without a pasted invitation can be credited as an estimated match only while the iOS handoff is off and probabilistic matching is on for the app. Android without the Play referrer, and an iPhone with the handoff on and the paste skipped, get no referral credit. A later exact claim replaces an estimate.

### Do referral links cost extra?

No. Referral links and crediting are included on every plan, with no extra fee per referral. Webhook endpoints have a limit: 1 per app on Free and 5 per app on paid plans.

### Does the SDK read the clipboard on iPhone?

No. The person taps a paste button, and the SDK reads only the text they paste. Skipping the paste leaves the install as it was.

### Who grants the reward?

You do. WarpLink records the referral and sends `referral.claimed`. Your backend checks your own rules and grants the credit, coupon, or payout in your own system.

### What happens if someone claims twice?

The first claim holds the credit. A repeat claim for the same installer answers `ignored` and names the referral that holds it, so a reward is never granted twice from our side.

### Which SDK version do I need?

SDK 1.2.0 or later on iOS, Android, React Native, and Flutter. Builds on 1.1.x keep working for links and installs but cannot read an invitation.

## Next

- [Receive referral webhooks](https://warplink.app/docs/guides/webhooks)
- [Referrals API](https://warplink.app/docs/api/referrals) and [Webhooks API](https://warplink.app/docs/api/webhooks)
- SDK pages: [iOS](https://warplink.app/docs/sdks/ios/attribution), [Android](https://warplink.app/docs/sdks/android/attribution), [React Native](https://warplink.app/docs/sdks/react-native/attribution), [Flutter](https://warplink.app/docs/sdks/flutter/attribution)
