# Referral panel

An invite-a-friend panel inside your own product, or your own screen on the same data, with sessions your server mints.

A **referral panel** puts "invite a friend" inside your own product. A signed-in
user opens it, sees their personal link, how to share it, and what each friend
they brought has earned them, and saves how they want to be paid. Bolt Claw
counts the clicks, signups and payments, applies your [reward rules](https://boltclaw.ai/docs/rewards),
holds and pays the rewards, and tells your server what happened.

There are two ways in, and they share the same data:

- **Our panel.** A short script puts a finished panel on your page, in the look
  and words you choose. About ten minutes of work.
- **Your own screen.** Read exactly what the panel shows, as data, from your
  server (`GET /v1/referrals/programs/{id}/referrers/{external_id}`) or from the
  browser with a session token, and draw it yourself. Every history row carries a
  stable `reason_code`, so your screen can translate it.

The people in a panel are **referrers**: your own users, identified by *your* id
for them (`external_id`). They have no Bolt Claw account and never see yours.
The package that wraps all of this is **`@boltclaw/referrals`**. It is
published separately from this API: you can use everything here without it, with
nothing but `fetch` and one script tag.

## Quickstart

Four steps, in this order.

**1. Switch it on and list your origins.** In the dashboard, open the program's
**In your app** tab, or send the same settings to the API:

```bash
curl -X PATCH https://api.boltclaw.ai/v1/referrals/programs/$PROGRAM_ID \
  -H "Authorization: Bearer $BOLTCLAW_KEY" -H "Content-Type: application/json" \
  -d '{"embed_enabled": true, "embed_origins": ["https://app.example.com"]}'
```

`embed_origins` are the sites allowed to show the panel: `https` only (and
`http://localhost:3000`, with a port, while you develop), no wildcard, no path, at most ten. Any other site that tries to frame
the panel is refused by the browser. The program's `embed` object holds the
public **embed key** your page passes as `embedKey`.

**2. Create a key with the "Referral panel" preset.** That is the single scope
`referrals:embed`. It mints panel sessions and creates, updates and reads
referrers, and nothing else: a leaked key can show somebody a referral panel and
cannot touch a program, a partner or a payout. Keep it on your server.

**3. Mint a token on your server.** When a signed-in user opens the page, call
`POST /v1/referrals/programs/{id}/embed-tokens` with your own id for them. The
call makes them a referrer if they are not one yet, and returns a token that
works for that one person in that one program:

```bash
curl -X POST https://api.boltclaw.ai/v1/referrals/programs/$PROGRAM_ID/embed-tokens \
  -H "Authorization: Bearer $BOLTCLAW_KEY" -H "Content-Type: application/json" \
  -d '{"referrer": {"external_id": "user-1042", "name": "Jane"}, "ttl_seconds": 3600}'
```

```json
{
  "token": "bcet_Kq3...",
  "expires_at": "2026-11-02T11:30:00.000Z",
  "referrer": { "id": "a3c5e7f9-1b2d-4c6e-8f0a-2b4d6f8a0c1e", "external_id": "user-1042", "status": "approved", "link_url": "https://boltclaw.ai/r/k3f9x2", "clicks": 0, "friends": 0, "friends_paid": 0, "earned_minor": 0, "currency": "USD" }
}
```

**The token is shown once.** Hand it to the page and do not store or log it. It
lives `ttl_seconds` (60 to 86,400, default 3,600). `name` is optional plain text
for the panel's greeting; `email` is optional and only used to recognise the same
person again (it is never stored as an address). Pass `"mode": "preview"` and no
`referrer` to get a token that shows sample data, to try your look and words
before you launch.

**4. Mount the panel.** Your page gets the token from your server and gives it
to the panel. In plain HTML and JavaScript:

```html
<div id="referrals"></div>
<script src="https://boltclaw.ai/referrals/embed.js"></script>
<script>
  fetch("/api/referral-token", { credentials: "same-origin" })
    .then((r) => r.json())
    .then(({ token }) => {
      const panel = BoltClawReferrals.mount(document.getElementById("referrals"), {
        token,
        embedKey: "YOUR_EMBED_KEY",
      });
      // A token runs out; ask your server for a new one and hand it over.
      panel.on("expired", async () => {
        const next = await (await fetch("/api/referral-token")).json();
        panel.update({ token: next.token });
      });
    });
</script>
```

To show it as a dialog instead, call `BoltClawReferrals.open({ token, embedKey })`
from a button, and `BoltClawReferrals.close()` to close it. A dialog traps focus,
closes on Escape or a click outside, stops the page behind it from scrolling and
returns focus to the button.

### React

```tsx
import { ReferralPanel } from "@boltclaw/referrals/react";

<ReferralPanel token={token} embedKey="YOUR_EMBED_KEY" mode="inline" />

{/* a dialog */}
<ReferralPanel token={token} embedKey="YOUR_EMBED_KEY" mode="modal" open={open} onClose={() => setOpen(false)} />
```

### Web component

```html
<script type="module">import "@boltclaw/referrals/element";</script>
<boltclaw-referrals token="..." embed-key="YOUR_EMBED_KEY" mode="inline"></boltclaw-referrals>
```

The loader's events are re-dispatched as DOM events with the same names, the
payload in `detail`.

### Minting the token on your server

Next.js, as a server action:

```ts
"use server";
import { ReferralsServer } from "@boltclaw/referrals";

const PROGRAM_ID = "YOUR_PROGRAM_ID"; // from the dashboard, or GET /v1/referrals/programs

const referrals = new ReferralsServer({
  apiKey: process.env.BOLTCLAW_KEY!, // never in a browser
  programId: PROGRAM_ID,
});

export async function referralToken() {
  const user = await currentUser();
  const { token } = await referrals.createEmbedToken({ externalId: user.id, name: user.firstName });
  return token;
}
```

Express, with nothing but `fetch`:

```js
app.get("/api/referral-token", requireLogin, async (req, res) => {
  const r = await fetch(`https://api.boltclaw.ai/v1/referrals/programs/${PROGRAM_ID}/embed-tokens`, {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.BOLTCLAW_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({ referrer: { external_id: String(req.user.id), name: req.user.firstName } }),
  });
  if (!r.ok) return res.status(502).json({ error: "referrals_unavailable" });
  const { token } = await r.json();
  res.set("Cache-Control", "no-store").json({ token });
});
```

Python, with `requests`:

```python
import os, requests

program_id = "YOUR_PROGRAM_ID"


def referral_token(user):
    r = requests.post(
        f"https://api.boltclaw.ai/v1/referrals/programs/{program_id}/embed-tokens",
        headers={"Authorization": f"Bearer {os.environ['BOLTCLAW_KEY']}"},
        json={"referrer": {"external_id": str(user.id), "name": user.first_name}},
        timeout=10,
    )
    r.raise_for_status()
    return r.json()["token"]
```

## Your own screen

You do not have to use our panel. `GET /v1/referrals/programs/{id}/referrers/{external_id}`
returns exactly what the panel draws for one referrer, as JSON (`ReferrerPanelView`),
and the browser can read the same View with a token. `{external_id}` is your own id for
the user and **must be percent-encoded** in the path.

```bash
curl https://api.boltclaw.ai/v1/referrals/programs/$PROGRAM_ID/referrers/user-1042 \
  -H "Authorization: Bearer $BOLTCLAW_KEY"
```

The View holds the referrer's `link_url`, the offer (`headline`, `reward_text`,
three `steps`, the `rules` and `faq`), `stats` per currency, a `history` row for
each friend (at most 50, newest first), how they are paid (`payout`), the share
text for each channel (`share`), and the `look` and `copy` you set. It carries no
name, email or id of your customers: a friend is "Friend 3".

In React, `useReferrer(token)` returns `{ view, error, loading, expired, refresh }`
for your own components. In any other browser code, `createPanelClient({ token })`
from `@boltclaw/referrals` has `get()`, `share(channel)` and
`savePayoutDetails({ method, email })`, and its `view` is the same data in
camelCase.

**Translate by code.** Each history row has a `stage` and a `reason_code`. The
English `reason` is a convenience; the codes are stable. They are never renamed or
reused, and a new one only ever joins the end of the list.

| `reason_code` | `stage` | Means |
|---|---|---|
| `awaiting_payment` | `joined` | The friend joined; nothing paid yet. |
| `on_hold_until` | `paid` | The friend paid. The reward waits out your program's hold, until `hold_until`, in case of a refund. |
| `reward_ready` | `paid` | The hold is over; the reward goes out in the next payout. |
| `reward_paid` | `rewarded` | The reward has been paid. |
| `under_review` | `paid` | The program is checking it before it can be paid. |
| `refunded` | `not_counted` | The friend's payment was refunded, so there is no reward. |
| `no_reward` | `paid` | The program pays nothing for this event. |
| `referrer_not_approved` | `not_counted` | The referrer is not approved in this program, so nothing is earned. |

The four stages are `joined`, `paid`, `rewarded` and `not_counted`. A row's
`hold_until` is set only for `on_hold_until`; its `reward` is the amount earned so
far, net of refunds, or `null`.

## What the panel tells your page

The loader reports what happens inside the panel as events. The handler receives the
payload without the internal `source`, `version` and `type` fields.

| Event | Payload | When |
|---|---|---|
| `ready` | none | The first View is drawn. |
| `resize` | `{ height }` | The content height changed (100 to 4000 pixels). Inline panels resize themselves. |
| `copied` | `{ what: "link" }` | The referrer copied their link. |
| `shared` | `{ channel }` | A share button, copy or QR was used. |
| `expired` | none | The token is no longer valid. Mint a new one and call `update({ token })`. |
| `closed` | none | The dialog was closed. |
| `error` | `{ code }` | A short stable word, never a message from a server. |
| `inviteRequested` | `{ email }` | A friend's address was typed into "send from my own system". Only if you turned that on. |

### Send invites from my own system

Some products would rather send the invitation themselves, from their own domain.
Set `invite_from_own_system: true` in the panel's settings and the panel shows an
email field. When the referrer fills it in, the panel does not send anything: it
hands the address to **your page** as an `inviteRequested` event, and you email it
however you like. **Bolt Claw never sees or emails that address.** The panel makes
no request carrying an email to us except the referrer's own payout form.

```js
panel.on("inviteRequested", ({ email }) => {
  fetch("/api/send-invite", { method: "POST", body: JSON.stringify({ email }) });
});
```

## Look and words

Everything below is `embed_config` on the program (`PATCH /v1/referrals/programs/{id}`,
or the **In your app** tab, which shows a live preview). It is sent whole: a new
`embed_config` replaces the old one. Every key is optional, an unknown key is a
`400`, and the whole object is at most 8 KB.

| Key | |
|---|---|
| `accent` | Accent colour, like `#2A9D8F`. |
| `radius` | Corner radius, 0 to 24 pixels. |
| `theme` | `light`, `dark` or `auto` (the visitor's device). |
| `link_mode` | `boltclaw` (the default): the referrer's link is `/r/{code}`, which counts clicks. `direct`: your own site with `?via={code}`, which names no other domain; clicks are then counted only when your server reports them. |
| `invite_from_own_system` | The email field above. |
| `channels` | Which share buttons to show, in order: `native`, `whatsapp`, `x`, `linkedin`, `facebook`, `messenger`, `telegram`, `sms`, `email`. |
| `copy` | Your own words for any of the panel's text, such as `headline`, `subline`, `step_1` to `step_3`, `copy_button`, `payout_title` and `expired_title`. Each has its own limit, from 30 to 240 characters. |
| `messages` | The text each share button pre-fills, up to 280 characters. `{link}` is replaced by the referrer's link (a message without it gets the link on a new line) and `{program}` by the program's name. |
| `email_subject`, `email_body` | The email the "email a friend" button starts. |

All of it is plain text, shown as text and never as HTML. A `copy` key you leave
out keeps the panel's own English. `approve_referrers` (default `true`) decides
whether a new referrer is approved at once or waits for you as `pending`.

## Security

The panel was designed so that the worst thing a stolen token can do is show
somebody a referral panel.

- **Mint tokens on your server, and never ship an API key to a browser.** The
  page only ever holds a token for one person. `@boltclaw/referrals`'s server
  helper refuses to start where `window` exists.
- **Tokens are short-lived.** One hour by default, at most 24 hours. When one runs
  out the panel emits `expired` and asks your page for a new one.
- **List your origins.** The panel can only be framed by the sites in
  `embed_origins`. The panel's page also sends a policy that lets it load nothing
  and contact nothing except Bolt Claw.
- **The token travels in the URL fragment.** The loader puts it after the `#`, so
  it is never sent to a server or in a `Referer` header, and the panel removes it
  from the address the moment it has read it. It is kept in memory only: no
  cookie, no local storage. A page and the panel talk by messages that each side
  accepts only from the other, in a fixed shape.
- **One referrer, one program.** A token opens exactly one referrer's panel in one
  program. It cannot read another referrer, another program, or anything else in
  your account.
- **What a stolen token can do** is read that one referrer's panel (their link,
  their numbers, their history) until it expires, count a share, and set payout
  details for them, which is why the panel never shows the saved address back and
  why `referral.referrer.payout_details_changed` exists: tell your user when it
  fires. It cannot mint another token, change a program, or move money.
- **To end every session at once**, call
  `POST /v1/referrals/programs/{id}/embed/revoke-sessions`. Every open panel shows
  "This link to your referrals has expired" and asks your page for a new token.
  To make every install that carries the old public key stop, rotate it with
  `POST /v1/referrals/programs/{id}/embed/rotate-key`.
- **An AI assistant can never mint a session.** `referrals:embed` cannot be carried
  by an assistant's key, and the mint call is refused for one even with
  `referrals:write`.

## Webhooks

Four events tell your server what happened to one of your referrers. Their bodies
carry your own `external_id` for the user. See [Webhooks](https://boltclaw.ai/docs/webhooks) for the
signature and delivery rules.

| Event | Extra fields |
|---|---|
| `referral.referrer.created` | `program_id`, `referrer_id`, `external_id` |
| `referral.referrer.conversion` | `program_id`, `referrer_id`, `external_id`, `conversion_id`, `kind` (`signed_up` or `paid`) |
| `referral.referrer.reward_earned` | `program_id`, `referrer_id`, `external_id`, `commission_id`, `amount_minor`, `currency`, `status` |
| `referral.referrer.payout_details_changed` | `program_id`, `referrer_id`, `external_id`, `method` (`paypal` or `wise`) |

## Erasing a referrer

`DELETE /v1/referrals/referrers/{id}` erases a person's referral data on their
request. `{id}` is the referrer's `id` from `ReferrerView`, not your `external_id`.
It removes their name, their payout details, the way their address was recognised,
their panel sessions and their share counts, and ends their link. It **keeps what
they earned** in the commission and payout records, as an anonymous row, because
your books and your tax records need those amounts. It cannot be undone, and an AI
assistant's key is always asked for a person's approval first
(`referral.referrer.erase`).

`PATCH /v1/referrals/referrers/{id}` approves or bans one without erasing them. A
banned referrer earns nothing from then on.

## Limits

Referrers have **their own allowance**, `maxRefReferrers`: **Free 100,000**, and
more on the paid plans. They are never counted as partners, so a busy panel does
not use up the partner allowance. A new referrer past the allowance is
`402 PLAN_LIMIT` (`REF_REFERRER_LIMIT_REACHED`); an existing one is never
affected. Minting is also rate-limited per key: past the ceiling the answer is
`429 RATE_LIMITED`, and one referrer can hold at most 200 live sessions, so reuse
a token until it expires rather than minting one per page view.

<!-- https://boltclaw.ai/docs/referral-panel as markdown. Generated from openapi.yaml; API version 1.22.0. -->
<!-- Every /docs page answers as markdown: add .md to its path, or send Accept: text/markdown. -->
