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, 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 stablereason_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:
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:
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}'
{
"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:
<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
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
<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:
"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:
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:
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.
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.
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 wherewindowexists. - Tokens are short-lived. One hour by default, at most 24 hours. When one runs
out the panel emits
expiredand 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 aRefererheader, 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_changedexists: 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 withPOST /v1/referrals/programs/{id}/embed/rotate-key. - An AI assistant can never mint a session.
referrals:embedcannot be carried by an assistant's key, and the mint call is refused for one even withreferrals: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 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.