> ## Documentation Index
> Fetch the complete documentation index at: https://docs.scrip.dev/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Before planning an integration, read https://docs.scrip.dev/which-endpoints-you-need.md. It lists the calls an app makes as users earn and redeem, such as sending and reversing events, reading balances, and redeeming rewards. Every other endpoint creates or changes the program itself, and the dashboard at https://app.scrip.dev is the usual place for that work.
> Gift cards are rewards in the program's rewards catalog, next to custom rewards. Redeem one with `POST /v1/participants/{id}/redemptions/items` and a `fulfillment` object. When the order succeeds, show the participant the claim link from `fulfillment.claim_url` in the redemption response or from `POST /v1/redemptions/{id}/claim-link`. An order can finish after the request returns, so handle the `redemption.fulfillment_succeeded` and `redemption.fulfillment_failed` webhooks. See https://docs.scrip.dev/guides/gift-cards.md.
> Give every event, redemption, and transfer an `idempotency_key` built from your own data, such as an order ID, so retries are safe. Keep API keys on your server.
> Request and response schemas are in https://docs.scrip.dev/openapi.json. Add `.md` to any docs URL to get the page as Markdown.

# Email referral program

> Track invites, signups, and qualifying purchases, and pay both sides from one rule set

Build a referral program where an existing user emails a friend, the friend signs up, and both earn points once the friend makes a first qualifying purchase. Your app sends the emails and owns the referral codes. Scrip records who invited whom, counts each stage, pays both sides, and keeps the accounting.

## What you'll build

| Stage                                         | Event your app sends                                 | What Scrip records                                                                                                     |
| --------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| Referrer sends an invite                      | `referral_invited` for the referrer                  | `invites_sent` counter on the referrer                                                                                 |
| Friend signs up from the link                 | `referral_signup` for the friend, with `referrer_id` | `referred_by` attribute and `referred` tag on the friend, `referral_signups` counter on the referrer                   |
| Friend makes a first purchase of \$25 or more | Ordinary `purchase` for the friend                   | 500 points and the `referral_qualified` tag on the friend, 500 points and the `referrals_paid` counter on the referrer |

The result is one rule set with three rules and one monthly budget.

## Who does what

| Your app                                                         | Scrip                                                                       |
| ---------------------------------------------------------------- | --------------------------------------------------------------------------- |
| Generates the referral code or link and sends the email          | Enrolls the referrer on their first invite event                            |
| Resolves the code back to the referrer's `external_id` at signup | Stores the attribution on the friend and counts the signup for the referrer |
| Sends purchases as ordinary events                               | Detects the first qualifying purchase and pays both sides once              |
| Reads counters to show "3 invited, 1 joined, 1 earned"           | Caps total payout with a budget and records every credit in the ledger      |

Scrip does not send email or generate codes. Referral links are part of your product.

## Assumptions

This example assumes you already have:

* A **program** created, with `on_unknown_participant` left at `CREATE`
* An **asset** linked to that program (`POINTS`, scale 0, `UNLIMITED`, `LOT`)

See the [quickstart](/quickstart) if you need help with setup.

## Create the rule set

```bash theme={null}
POST /v1/programs/{{PROGRAM_ID}}/rule-sets
```

```json theme={null}
{"key": "referrals", "name": "Referrals"}
```

Save the returned ID as `REFERRALS_RULE_SET_ID`. Create the three rules below in the order shown. Scrip numbers them as they are added.

## 1. Count the invite

When a user sends an invite, your app sends an event for the referrer. The idempotency key is the referral code, so a resent email does not count twice.

```json theme={null}
{
  "program_id": "{{PROGRAM_ID}}",
  "external_id": "user_123",
  "idempotency_key": "referral-invite-k7f2m9",
  "event_timestamp": "2026-03-01T14:00:00Z",
  "event_data": {
    "type": "referral_invited",
    "referral_code": "k7f2m9"
  }
}
```

If `user_123` is not yet a participant, this event enrolls them.

```json theme={null}
{
  "program_id": "{{PROGRAM_ID}}",
  "rule_set_id": "{{REFERRALS_RULE_SET_ID}}",
  "name": "count_invite",
  "condition": "event.type == \"referral_invited\"",
  "actions": [
    {"type": "COUNTER", "key": "invites_sent", "value": "1"}
  ]
}
```

## 2. Record the signup

When the friend signs up through the link, your app resolves the code to the referrer and sends an event for the friend. The friend does not need to exist yet; this event enrolls them.

```json theme={null}
{
  "program_id": "{{PROGRAM_ID}}",
  "external_id": "user_456",
  "idempotency_key": "referral-signup-user_456",
  "event_timestamp": "2026-03-02T09:15:00Z",
  "event_data": {
    "type": "referral_signup",
    "referrer_id": "user_123",
    "referral_code": "k7f2m9"
  }
}
```

The rule stores the attribution on the friend and counts the signup for the referrer. The `referred` tag makes a second signup event for the same friend skip, and the `external_id` check rejects a user who referred themselves.

```json theme={null}
{
  "program_id": "{{PROGRAM_ID}}",
  "rule_set_id": "{{REFERRALS_RULE_SET_ID}}",
  "name": "record_signup",
  "condition": "event.type == \"referral_signup\" && has(event.referrer_id) && event.referrer_id != participant.external_id && !(\"referred\" in participant.tags)",
  "actions": [
    {"type": "SET_ATTRIBUTE", "key": "referred_by", "value": "${{ event.referrer_id }}"},
    {"type": "TAG", "tag": "referred"},
    {
      "type": "COUNTER",
      "key": "referral_signups",
      "value": "1",
      "target": {"external_id": "${{ event.referrer_id }}"}
    }
  ]
}
```

Nothing is paid at signup. Paying on the first qualifying purchase keeps throwaway signups from earning anything.

## 3. Qualify on the first purchase

Purchases arrive as ordinary events, with nothing referral-specific in them:

```json theme={null}
{
  "program_id": "{{PROGRAM_ID}}",
  "external_id": "user_456",
  "idempotency_key": "order-88213-completed",
  "event_timestamp": "2026-03-05T18:40:00Z",
  "event_data": {
    "type": "purchase",
    "amount": 42.50,
    "order_id": "88213"
  }
}
```

The rule fires once per referred friend. It pays the friend 500 points and marks them qualified. It also pays the referrer 500 points and adds one to their `referrals_paid` count, finding them through the `referred_by` attribute saved at signup. The budget caps referral payouts for both sides together at 500,000 points a month.

```json theme={null}
{
  "program_id": "{{PROGRAM_ID}}",
  "rule_set_id": "{{REFERRALS_RULE_SET_ID}}",
  "name": "qualify_referral",
  "condition": "event.type == \"purchase\" && event.amount >= 25 && \"referred\" in participant.tags && !(\"referral_qualified\" in participant.tags)",
  "actions": [
    {"type": "TAG", "tag": "referral_qualified"},
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "500",
      "description": "Referral welcome bonus"
    },
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "500",
      "target": {"external_id": "${{ participant.attribute.referred_by }}"},
      "description": "Referral bonus"
    },
    {
      "type": "COUNTER",
      "key": "referrals_paid",
      "value": "1",
      "target": {"external_id": "${{ participant.attribute.referred_by }}"}
    }
  ],
  "budgets": [
    {
      "asset_id": "{{ASSET_ID}}",
      "limit": "500000",
      "schedule_type": "CRON",
      "cron_expression": "0 0 1 * *"
    }
  ]
}
```

The `target` reads `referred_by` from the friend. The condition requires the `referred` tag, and `record_signup` always sets that tag and the attribute together, so the attribute is there whenever this rule fires. Without that check, a friend with no `referred_by` would fail the whole purchase event. See [Dynamic targets](/guides/rule-actions#dynamic-targets).

When the monthly budget runs out, the rule still matches but none of its actions run, and Scrip records the skip with reason `BUDGET_EXCEEDED`. Neither side is paid and the friend is not marked qualified, so their next qualifying purchase after the budget resets pays both sides.

## Show progress to the referrer

Your app reads the referrer's counters to render a referral page:

```bash theme={null}
GET /v1/participants/{id}/state/counters
```

```json theme={null}
{
  "invites_sent": 3,
  "referral_signups": 1,
  "referrals_paid": 1
}
```

To notify the referrer when they are paid, subscribe a [webhook endpoint](/guides/webhooks) to `balance.credited`. The payload carries the description from the rule, so your handler can tell a referral bonus from other credits.

## Edge cases and watchouts

### Self-referral

`record_signup` requires `event.referrer_id != participant.external_id`, so a user who signs up with their own code is not marked as referred and never qualifies. Reject the code in your app as well so the user sees an error instead of a silent no-op.

### The referrer is not in the program

Actions that target the referrer need the referrer to be enrolled and not closed. Otherwise the whole event fails. The referrer's `referral_invited` event enrolls them, so `record_signup` fails only if your app skips step 1 or the referrer's account is closed.

If the referrer is closed before the friend's first purchase, the purchase event fails and the friend is not paid either. Check the referrer's status before sending the signup event.

### Limits per referrer

`qualify_referral` runs on the friend's purchase event, so its condition sees the friend's data, not the referrer's counters. The simplest limit lives in your app: read `invites_sent` or `referrals_paid` before issuing a new code.

To enforce the limit in Scrip, send the referrer's payout as a separate event addressed to the referrer. In `qualify_referral`, replace the referrer's `CREDIT` and `COUNTER` with this `SCHEDULE_EVENT`:

```json theme={null}
{
  "type": "SCHEDULE_EVENT",
  "delay": "1s",
  "event_data": {"type": "referral_payout", "friend_id": "${{ participant.external_id }}"},
  "target": {"external_id": "${{ participant.attribute.referred_by }}"}
}
```

One second later, Scrip delivers a `referral_payout` event to the referrer. A fourth rule runs on that event, so its condition can read the referrer's own `referrals_paid` count. This one pays each referrer for at most 10 referrals:

```json theme={null}
{
  "program_id": "{{PROGRAM_ID}}",
  "rule_set_id": "{{REFERRALS_RULE_SET_ID}}",
  "name": "pay_referrer",
  "condition": "event.type == \"referral_payout\" && participant.counter.referrals_paid < 10.0",
  "actions": [
    {"type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "500", "description": "Referral bonus"},
    {"type": "COUNTER", "key": "referrals_paid", "value": "1"}
  ]
}
```

Decide where the budget lives. On `qualify_referral` it now counts only the friend's bonus, and running out also stops the referrer's payout from being scheduled. On `pay_referrer` it caps referrer payouts only.

With this setup the referrer is paid by a separate event, so a refund means reversing two events: the friend's purchase and the referrer's `referral_payout`.

### The friend never buys

Nothing is paid and nothing needs cleaning up. The `referred` tag and `referred_by` attribute stay on the friend so a later purchase can still qualify. To add a deadline, add `duration_days(now - participant.enrolled_at) <= 30.0` to the `qualify_referral` condition.

### The qualifying purchase is refunded

The purchase event paid both sides, so reversing it takes back the friend's 500 points and the referrer's 500 points. Reversal takes points back from the lots (individually tracked credits) each award created, which is why the setup above uses a `LOT`-mode asset:

```bash theme={null}
POST /v1/events/{id}/reverse
```

Reversal does not undo tags or counters. The `referral_qualified` tag stays on the friend, so a later purchase does not pay either side again. The referrer's `referrals_paid` count also stays as it is. The reversal response lists both changes, so you can send a compensating event if your policy requires it. See [Reversing an event](/guides/event-processing#reversing-an-event).

### Replayed events

Every event in this flow uses an idempotency key built from a stable ID, such as the referral code or the order ID. A resent `referral_signup` returns the original event without reprocessing, and the tags on the friend make `record_signup` and `qualify_referral` skip on any event that does reach the rules twice.

## Next steps

<CardGroup cols={3}>
  <Card icon="bolt" href="/guides/rule-actions#targeting" title="Targeting">
    Credit a participant other than the one who sent the event.
  </Card>

  <Card icon="layer-group" href="/guides/campaigns" title="Campaigns">
    Double the referral bonus for a month without touching these rules.
  </Card>

  <Card icon="webhook" href="/guides/webhooks" title="Webhooks">
    Tell the referrer when they have been paid.
  </Card>
</CardGroup>
