@boltclaw/referrals 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +500 -3
- package/dist/api-types.d.ts +245 -0
- package/dist/api-types.js +2 -0
- package/dist/api-types.js.map +1 -0
- package/dist/assistant-tools.d.ts +40 -0
- package/dist/assistant-tools.js +120 -0
- package/dist/assistant-tools.js.map +1 -0
- package/dist/assistant.d.ts +62 -0
- package/dist/assistant.js +305 -0
- package/dist/assistant.js.map +1 -0
- package/dist/case.d.ts +2 -0
- package/dist/case.js +30 -0
- package/dist/case.js.map +1 -0
- package/dist/client.d.ts +22 -0
- package/dist/client.js +80 -0
- package/dist/client.js.map +1 -0
- package/dist/element.d.ts +4 -0
- package/dist/element.js +204 -0
- package/dist/element.js.map +1 -0
- package/dist/errors.d.ts +19 -0
- package/dist/errors.js +62 -0
- package/dist/errors.js.map +1 -0
- package/dist/generated/api.d.ts +6477 -0
- package/dist/generated/api.js +891 -0
- package/dist/generated/api.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/loader.d.ts +118 -0
- package/dist/loader.js +524 -0
- package/dist/loader.js.map +1 -0
- package/dist/middleware.d.ts +100 -0
- package/dist/middleware.js +378 -0
- package/dist/middleware.js.map +1 -0
- package/dist/origin.d.ts +3 -0
- package/dist/origin.js +22 -0
- package/dist/origin.js.map +1 -0
- package/dist/react.d.ts +69 -0
- package/dist/react.js +328 -0
- package/dist/react.js.map +1 -0
- package/dist/server-index.d.ts +17 -0
- package/dist/server-index.js +9 -0
- package/dist/server-index.js.map +1 -0
- package/dist/server.d.ts +106 -0
- package/dist/server.js +511 -0
- package/dist/server.js.map +1 -0
- package/dist/typed-code.d.ts +78 -0
- package/dist/typed-code.js +234 -0
- package/dist/typed-code.js.map +1 -0
- package/dist/types.d.ts +254 -0
- package/dist/types.js +61 -0
- package/dist/types.js.map +1 -0
- package/dist/webhooks.d.ts +20 -0
- package/dist/webhooks.js +90 -0
- package/dist/webhooks.js.map +1 -0
- package/package.json +97 -6
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bolt Claw
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,500 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# @boltclaw/referrals
|
|
2
|
+
|
|
3
|
+
Bolt Claw referrals for your own product. Call any referral operation through a typed client
|
|
4
|
+
generated from the API's own specification; report signups (with both dates right), sales and
|
|
5
|
+
refunds from your server; keep the referral code through Safari with a proxy for Next.js and
|
|
6
|
+
Express that reads every query name your links use; verify Bolt
|
|
7
|
+
Claw's webhooks; read the program's live setup status and send a test sale. Put an "invite a
|
|
8
|
+
friend" panel in your app, mint the short-lived tokens it needs from your server, and read a
|
|
9
|
+
referrer's numbers to draw a screen of your own. Welcome the friend who arrived through a link with
|
|
10
|
+
their offer, apply the discount at Stripe Checkout from your server, and ask a new customer to share
|
|
11
|
+
on your thank-you page. No runtime dependencies; ESM only; Node 20+, Deno, Bun, edge runtimes and
|
|
12
|
+
modern browsers.
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm install @boltclaw/referrals
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
**0.4.0 is not on npm yet.** It is built and its tests pass; publishing it is the maintainers'
|
|
19
|
+
step. Until then `npm install @boltclaw/referrals` answers 404, and every page of Bolt Claw's
|
|
20
|
+
documentation that uses the package shows the same thing without it.
|
|
21
|
+
|
|
22
|
+
React is an optional peer dependency, needed only for `@boltclaw/referrals/react`.
|
|
23
|
+
`@boltclaw/referrals/server` holds the server half alone (the server class, the middleware and
|
|
24
|
+
`verifyWebhook`), with nothing that touches a page: import that one in Next.js middleware.
|
|
25
|
+
|
|
26
|
+
## How it fits together
|
|
27
|
+
|
|
28
|
+
1. **Your server** holds an API key (scope `referrals:embed`) and mints an embed token for the
|
|
29
|
+
signed-in user: `ReferralsServer.createEmbedToken`.
|
|
30
|
+
2. **Your page** gives that token to the panel, by script tag, React component or web component.
|
|
31
|
+
3. The panel is an iframe served by Bolt Claw. It reads the token from the URL fragment, keeps it in
|
|
32
|
+
memory, and talks only to Bolt Claw.
|
|
33
|
+
|
|
34
|
+
You also need the program's public **embed key** and the list of **origins** that may frame the
|
|
35
|
+
panel (Dashboard, Referrals, In your app).
|
|
36
|
+
|
|
37
|
+
## Ways in
|
|
38
|
+
|
|
39
|
+
### 1. Script tag
|
|
40
|
+
|
|
41
|
+
The hosted loader installs a global, with the same behaviour as this package's `mountPanel`:
|
|
42
|
+
|
|
43
|
+
```html
|
|
44
|
+
<div id="referrals"></div>
|
|
45
|
+
<script src="https://boltclaw.ai/referrals/embed.js"></script>
|
|
46
|
+
<script>
|
|
47
|
+
const panel = BoltClawReferrals.mount(document.getElementById("referrals"), {
|
|
48
|
+
token: "<token your server minted>",
|
|
49
|
+
embedKey: "<your embed key>",
|
|
50
|
+
});
|
|
51
|
+
panel.on("expired", async () => panel.update({ token: await fetchNewToken() }));
|
|
52
|
+
</script>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The thank-you card is `mode: "thank-you"` in the options, or `data-mode="thank-you"` on the element
|
|
56
|
+
you mount into (see [The thank-you card](#the-thank-you-card)).
|
|
57
|
+
|
|
58
|
+
### 2. Modules (no framework)
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
import { mountPanel, openPanel, closePanel } from "@boltclaw/referrals";
|
|
62
|
+
|
|
63
|
+
const panel = mountPanel(document.getElementById("referrals"), { token, embedKey });
|
|
64
|
+
panel.on("copied", () => console.log("link copied"));
|
|
65
|
+
|
|
66
|
+
openPanel({ token, embedKey }); // a modal with a focus trap; Escape or the backdrop closes it
|
|
67
|
+
closePanel();
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`mountPanel` and `openPanel` return a handle: `update({ token })`, `refresh()`, `unmount()`,
|
|
71
|
+
`on(type, fn)`, `off(type, fn)` and `iframe`. Options: `token`, `embedKey`, `origin` (default
|
|
72
|
+
`https://boltclaw.ai`), `theme`, and `mode: "thank-you"` for the compact card (any other value is the
|
|
73
|
+
whole panel). In a modal, "Not right now" on the card emits `dismissed` and then closes it.
|
|
74
|
+
|
|
75
|
+
### 3. React
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
import { ReferralPanel, useReferrer } from "@boltclaw/referrals/react";
|
|
79
|
+
|
|
80
|
+
<ReferralPanel token={token} embedKey="ek_..." onEvent={(type, payload) => track(type)} />;
|
|
81
|
+
|
|
82
|
+
// A modal:
|
|
83
|
+
<ReferralPanel token={token} embedKey="ek_..." mode="modal" open={open} onClose={() => setOpen(false)} />;
|
|
84
|
+
|
|
85
|
+
// The thank-you card, inline; hide it when they choose "Not right now":
|
|
86
|
+
{ask && <ReferralPanel token={token} embedKey="ek_..." mode="thank-you" onDismiss={() => setAsk(false)} />}
|
|
87
|
+
|
|
88
|
+
// Your own screen, from the same data the panel draws:
|
|
89
|
+
const { view, error, loading, expired, refresh } = useReferrer(token, { refreshMs: 60_000 });
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
It renders on a server (nothing touches `window` outside an effect). A new `token` prop is passed to
|
|
93
|
+
the running panel without re-creating it.
|
|
94
|
+
|
|
95
|
+
### 4. Web component
|
|
96
|
+
|
|
97
|
+
```html
|
|
98
|
+
<script type="module">import "@boltclaw/referrals/element";</script>
|
|
99
|
+
<boltclaw-referrals token="..." embed-key="ek_..." mode="inline"></boltclaw-referrals>
|
|
100
|
+
<boltclaw-referrals token="..." embed-key="ek_..." mode="modal" open></boltclaw-referrals>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Attributes: `token`, `embed-key`, `mode` (`inline`, `modal` or `thank-you`), `origin`, `theme`, and
|
|
104
|
+
`open` (modal).
|
|
105
|
+
The loader's events are re-dispatched as DOM events with the same names; the payload is in `detail`.
|
|
106
|
+
|
|
107
|
+
### 5. Headless (your own screen)
|
|
108
|
+
|
|
109
|
+
In the browser, with a token:
|
|
110
|
+
|
|
111
|
+
```js
|
|
112
|
+
import { createPanelClient, isExpired } from "@boltclaw/referrals";
|
|
113
|
+
|
|
114
|
+
const client = createPanelClient({ token });
|
|
115
|
+
const view = await client.get(); // link, stats, rewards, history (with stable reasonCode), copy
|
|
116
|
+
await client.share("whatsapp"); // counts a share
|
|
117
|
+
await client.savePayoutDetails({ method: "paypal", email: "jane@example.com" });
|
|
118
|
+
await client.dismiss(); // "Not right now": shouldAsk answers false for the program's days
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`view` is the same View the panel draws, in camelCase. Translate history rows by `reasonCode`.
|
|
122
|
+
`view.grants` lists the referrer's rewards that are not cash ("1 month free") with a `statusText`
|
|
123
|
+
sentence; `view.ask.shouldAsk` and `view.thankYou` are what the thank-you card uses.
|
|
124
|
+
|
|
125
|
+
## The friend's welcome
|
|
126
|
+
|
|
127
|
+
A friend who arrives through a referral link carries a code your page's referral tag
|
|
128
|
+
(`https://boltclaw.ai/referrals/v2.js`, or `v1.js`) keeps for them. Show them the offer:
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
import { ReferralWelcome, useOffer } from "@boltclaw/referrals/react";
|
|
132
|
+
|
|
133
|
+
// Reads BoltClawReferral.code() and the public offer; renders nothing when there is none.
|
|
134
|
+
<ReferralWelcome className="my-welcome" />;
|
|
135
|
+
|
|
136
|
+
// Or draw your own:
|
|
137
|
+
const { offer, loading } = useOffer(); // or useOffer("jane-20")
|
|
138
|
+
if (offer) console.log(offer.inviter ?? "A friend", offer.gift, offer.conditions, offer.disclosure);
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`<ReferralWelcome />` is a `role="region"` labelled "Referral offer": "Jane invited you" (or "A
|
|
142
|
+
friend invited you" when the inviter did not agree to their name being shown), the gift as its
|
|
143
|
+
headline, the conditions, the disclosure whenever the inviter is rewarded (always show it), the
|
|
144
|
+
program's call to action if it set one, and a Close button. It carries the program's accent and
|
|
145
|
+
corner radius as `--bc-accent` and `--bc-radius` and otherwise leaves the styling to your
|
|
146
|
+
`className`. Text only, no remote asset. Props: `offer` (draw one you already have), `code`,
|
|
147
|
+
`origin`, `fetch`, `showCta`, `onClose`, `className`, `style`.
|
|
148
|
+
|
|
149
|
+
`useOffer` reads the offer once per code, with no cookie and no credentials, from
|
|
150
|
+
`https://boltclaw.ai/api/referrals/offer`. That read is for SHOWING the offer: the discount is
|
|
151
|
+
decided by your server at checkout.
|
|
152
|
+
|
|
153
|
+
## The discount at checkout
|
|
154
|
+
|
|
155
|
+
The browser sends your server the code it has; your server asks Bolt Claw whether THIS customer may
|
|
156
|
+
have the offer, and hands Stripe the discount:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
app.post("/api/checkout", requireLogin, async (req, res) => {
|
|
160
|
+
const code = typeof req.body.referral === "string" ? req.body.referral : null; // BoltClawReferral.code()
|
|
161
|
+
const offer = code ? await referrals.getOffer(code, { customerId: req.user.id, newCustomer: !req.user.hasPaid }) : null;
|
|
162
|
+
const session = await stripe.checkout.sessions.create({
|
|
163
|
+
mode: "subscription",
|
|
164
|
+
line_items: [{ price: process.env.PRICE_ID, quantity: 1 }],
|
|
165
|
+
client_reference_id: code ?? undefined,
|
|
166
|
+
metadata: { boltclaw_customer_id: req.user.id, ...(code ? { boltclaw_ref: code } : {}) },
|
|
167
|
+
subscription_data: code ? { metadata: { boltclaw_ref: code } } : undefined,
|
|
168
|
+
discounts: offer?.discounts, // [{ promotion_code }] or [{ coupon }], or [] when not eligible
|
|
169
|
+
success_url: "https://example.com/thanks",
|
|
170
|
+
cancel_url: "https://example.com/pricing",
|
|
171
|
+
});
|
|
172
|
+
res.json({ url: session.url, notice: offer && !offer.eligible ? offer.reason : null });
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`discounts` is the promotion code Bolt Claw knows in your Stripe when there is one (the friend's
|
|
177
|
+
code shows on the receipt), else the coupon, else nothing, and always nothing when the customer is
|
|
178
|
+
not eligible. `reasonCode` says why: `unknown_code`, `program_paused`, `self_referral`,
|
|
179
|
+
`already_referred` or `not_new`; `reason` is a sentence your checkout can show instead of an error.
|
|
180
|
+
With any other billing system, pass `offer.discount.code` the way it expects.
|
|
181
|
+
|
|
182
|
+
## The thank-you card
|
|
183
|
+
|
|
184
|
+
After a friend's first payment, Bolt Claw can make them a referrer of their own (the program's
|
|
185
|
+
"invite after purchase"). On your page after a purchase, ask them to share, at the right moment and
|
|
186
|
+
not every time:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
// Your server, on the thank-you page:
|
|
190
|
+
if (await referrals.shouldAsk(user.id)) {
|
|
191
|
+
const { token } = await referrals.createEmbedToken({ externalId: user.id, name: user.firstName });
|
|
192
|
+
// render <ReferralPanel mode="thank-you" token={token} ... />
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The card shows "Now give your friends 20% off", their link with a Copy button, the share buttons,
|
|
197
|
+
and "Not right now". After a share or a copy it says what happens next ("You get 1 month free once
|
|
198
|
+
they make their first payment."). "Not right now" is recorded through the token, on Bolt Claw's
|
|
199
|
+
side with no cookie, and fires `dismissed`; `shouldAsk` then answers false for the program's days
|
|
200
|
+
(30 by default).
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
## Server helpers
|
|
204
|
+
|
|
205
|
+
Run these on your server only.
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
import { ReferralsServer } from "@boltclaw/referrals";
|
|
209
|
+
|
|
210
|
+
const referrals = new ReferralsServer({
|
|
211
|
+
apiKey: process.env.BOLTCLAW_API_KEY!, // never in a browser
|
|
212
|
+
programId: process.env.BOLTCLAW_PROGRAM_ID!,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const { token, expiresAt } = await referrals.createEmbedToken({ externalId: user.id, name: user.firstName });
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Express:
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
app.get("/api/referral-token", requireLogin, async (req, res) => {
|
|
222
|
+
const { token } = await referrals.createEmbedToken({ externalId: req.user.id, name: req.user.firstName });
|
|
223
|
+
res.set("Cache-Control", "no-store").json({ token });
|
|
224
|
+
});
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Next.js server action:
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
"use server";
|
|
231
|
+
export async function referralToken() {
|
|
232
|
+
const user = await currentUser();
|
|
233
|
+
return (await referrals.createEmbedToken({ externalId: user.id, name: user.firstName })).token;
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
| Method | What it does |
|
|
238
|
+
| --- | --- |
|
|
239
|
+
| `createEmbedToken({ externalId, name?, email?, code?, ttlSeconds?, mode? })` | Mints a token and creates the referrer if new. `mode: "preview"` needs no referrer and shows sample data. Returns `{ token, expiresAt, referrer }`. |
|
|
240
|
+
| `upsertReferrer({ externalId, name?, email?, code? })` | `{ referrer, created }`. A `code` can only be chosen when created. |
|
|
241
|
+
| `getReferrer(externalId)` | Exactly what the panel shows this referrer. |
|
|
242
|
+
| `listReferrers({ status?, search?, limit?, cursor? })` | `{ data, nextCursor }`. |
|
|
243
|
+
| `updateReferrerStatus(referrerId, "approved" \| "banned")` | Needs `referrals:write`. |
|
|
244
|
+
| `eraseReferrer(referrerId, { approvalId? })` | Anonymises the referrer, keeps earned commissions. A key held by an assistant gets a 428 with `approvalUrl` first. |
|
|
245
|
+
| `getOffer(code, { customerId?, newCustomer? })` | The friend's offer and whether this customer may have it: `{ offer, eligible, reasonCode, reason, coupon, discounts }`. `discounts` goes straight to Stripe Checkout. |
|
|
246
|
+
| `getCustomerReferral(externalId)` | Who invited one of your customers: `{ invitedBy, invitedAt, via }` (`via` is `link` or `discount_code`). A customer the program never saw is a 404. |
|
|
247
|
+
| `shouldAsk(externalId)` | Whether to show the thank-you card now. False after "Not right now" (for the program's days), for a referrer that does not exist, a banned one, and while the program is paused. Any other failure throws. |
|
|
248
|
+
| `recordSignup({ user: { id, createdAt, email? }, code?, occurredAt?, eventId?, test? })` | A signup with both dates right: `customer.created_at` from your user record, `occurred_at` from now. Refuses the two equal to the second (when you set `occurredAt`) or the wrong way round. |
|
|
249
|
+
| `claimGrant(grantId)`, `markRewardsSeen(externalId, { through? })` | A person's claim of a claimable free month; your own screen showed their new rewards. |
|
|
250
|
+
| `recordLead({ customerId, eventId, email?, customerCreatedAt?, referralCode?, discountCode?, occurredAt?, test?, idempotencyKey? })` | A signup. `{ conversion, created }`; a repeated `eventId` is `created: false` and changes nothing. Needs `conversions:write`. |
|
|
251
|
+
| `recordSale({ customerId, eventId, amount, currency, referralCode?, discountCode?, discountApplied?, items?, test? })` | A paid sale in minor units. `items` (each with an `amount`) must add up to `amount`. |
|
|
252
|
+
| `recordRefund({ eventId, refundOf, amount, currency?, test? })` | This refund alone; `refundOf` is the sale's `eventId`. |
|
|
253
|
+
| `recordDispute({ eventId, refundOf, amount?, currency?, test? })` | A chargeback: the sale's commission is reversed in full. |
|
|
254
|
+
| `recordClick({ code, ip?, userAgent?, referrer?, country?, landingPath? })` | A click your own server saw on a partner's link. Hashed and classed by Bolt Claw, never stored; earns nothing by itself. `{ recorded, reason, programId }`. |
|
|
255
|
+
| `getIntegrationStatus()` | The live setup status: each part (`tag`, `billing`, `signups`, `sales`, `codes`, `panel`, `welcome`) `connected`, `warnings` or `not_connected`, with the fix. Needs `referrals:read`. |
|
|
256
|
+
| `sendTestSale({ idempotencyKey? })` | A whole TEST sale through the program, step by step. Needs `referrals:write`. |
|
|
257
|
+
| `listGrants({ status?, type?, recipient?, limit?, cursor? })`, `getGrant(id)` | The program's rewards that are not cash. |
|
|
258
|
+
|
|
259
|
+
Constructor options: `apiKey`, `programId`, `baseUrl` (default `https://api.boltclaw.ai/v1`; https
|
|
260
|
+
only, except localhost and 127.0.0.1), `fetch` (inject your own), `dangerouslyAllowBrowser`.
|
|
261
|
+
Nothing is retried for you.
|
|
262
|
+
|
|
263
|
+
## The typed client (generated)
|
|
264
|
+
|
|
265
|
+
`BoltClawClient` is compiled from Bolt Claw's OpenAPI specification (`npm run client:gen` in Bolt
|
|
266
|
+
Claw's repository): a type for every schema, with the wire's own snake_case names, and a method for
|
|
267
|
+
every referral operation and `ping`. Each method is one `fetch`: the key as a Bearer header, no
|
|
268
|
+
redirect followed, an `Idempotency-Key` only where the operation takes one. `ReferralsServer` below
|
|
269
|
+
calls it underneath, and exposes it as `server.api` for anything it has no method for.
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
import { BoltClawApiError, BoltClawClient } from "@boltclaw/referrals/server";
|
|
273
|
+
import type { ProgramStats } from "@boltclaw/referrals/api"; // every schema's type
|
|
274
|
+
|
|
275
|
+
const api = new BoltClawClient({ apiKey: process.env.BOLTCLAW_API_KEY! });
|
|
276
|
+
const stats: ProgramStats = await api.getReferralProgramStats({ id: programId, bucket: "month" });
|
|
277
|
+
console.log(stats.totals.sales, stats.rewards.given, stats.rewards.claimable_now);
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
await api.deleteReferralReward({ id: ruleId });
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if (error instanceof BoltClawApiError && error.needsApproval) {
|
|
283
|
+
// An AI assistant's key: show error.approval.url to a person, then send the identical request again.
|
|
284
|
+
await error.retry();
|
|
285
|
+
} else throw error;
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
`error.code` is one of the API's stable codes (`ErrorCode`, a union) or the client's own
|
|
290
|
+
`network_error` and `invalid_response`; branch on it, never on the sentence. `client.send(operationId,
|
|
291
|
+
input)` reaches any operation by its id and answers `{ status, data, requestId }`. It refuses to start
|
|
292
|
+
where `window` exists (an API key must never reach a browser), and imports nothing: Node 18+, Deno
|
|
293
|
+
and edge runtimes.
|
|
294
|
+
|
|
295
|
+
## Your program's numbers in your own admin
|
|
296
|
+
|
|
297
|
+
A key with the **Reporting (read-only)** preset holds `referrals:read` only, and the key form has
|
|
298
|
+
you pick the programs it may read. `getReferralProgramStats({ id })` answers clicks, signups, sales,
|
|
299
|
+
revenue and commission, and `rewards`: the rewards that are not cash given, claimed, claimable (and
|
|
300
|
+
`claimable_now`), expired, cancelled, reversed and refused, in total, per type and per bucket, live
|
|
301
|
+
data only (`rewards.test` counts test data apart).
|
|
302
|
+
|
|
303
|
+
## Reporting conversions from your server
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
const referrals = new ReferralsServer({ apiKey: process.env.BOLTCLAW_API_KEY!, programId: process.env.BOLTCLAW_PROGRAM_ID! });
|
|
307
|
+
|
|
308
|
+
// At signup: your user record, and the code your server kept (the bc_ref cookie).
|
|
309
|
+
await referrals.recordSignup({ user: { id: user.id, createdAt: user.createdAt }, code: req.cookies.bc_ref });
|
|
310
|
+
|
|
311
|
+
// At payment, if your billing system is not connected to Bolt Claw directly:
|
|
312
|
+
await referrals.recordSale({ customerId: user.id, eventId: invoice.id, amount: 4900, currency: "USD" });
|
|
313
|
+
await referrals.recordRefund({ eventId: refund.id, refundOf: invoice.id, amount: 1000 });
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
`recordSignup()` sets `customer.created_at` from your user record and `occurred_at` from now (or the
|
|
317
|
+
`occurredAt` you give, for a code entered later), under the event id `signup:<user id>`. It refuses,
|
|
318
|
+
before sending anything, a call whose two dates are equal to the second when you set `occurredAt`,
|
|
319
|
+
or whose `occurredAt` is before the account existed: the dates the wrong way round make every
|
|
320
|
+
account look new to the program's new-customer window.
|
|
321
|
+
|
|
322
|
+
`eventId` is YOUR id for the event and the idempotency key: retry with the same one, never a new
|
|
323
|
+
one. A key with the single scope `conversions:write` does nothing else, so it can sit in a checkout
|
|
324
|
+
server.
|
|
325
|
+
|
|
326
|
+
## Inside an AI assistant (your own MCP server)
|
|
327
|
+
|
|
328
|
+
`assistantTools()` gives your own MCP server two tools: `get_my_invite` (the person's code, link,
|
|
329
|
+
a message to share, their friends and rewards, as plain sentences in their language) and
|
|
330
|
+
`use_invite_code` (checks a code or a pasted link, says what it gives, and saves it only once the
|
|
331
|
+
person agreed). Each comes as a definition (`name`, `title`, `description`, `inputSchema`,
|
|
332
|
+
`outputSchema`, `annotations`) with a `handler`, plain objects any MCP library takes; the package
|
|
333
|
+
has no MCP dependency.
|
|
334
|
+
|
|
335
|
+
```ts
|
|
336
|
+
import { assistantTools } from "@boltclaw/referrals/server";
|
|
337
|
+
|
|
338
|
+
const kit = assistantTools({
|
|
339
|
+
apiKey: process.env.BOLTCLAW_API_KEY!, // referrals:read and conversions:write, limited to the program
|
|
340
|
+
programId: process.env.BOLTCLAW_PROGRAM_ID!,
|
|
341
|
+
// The person from YOUR MCP server's own sign-in, never from a tool argument.
|
|
342
|
+
customerOf: (ctx) => ctx.user && { id: ctx.user.id, locale: ctx.user.locale, createdAt: ctx.user.createdAt },
|
|
343
|
+
// What YOUR sign-in page saw when they signed in; never this request's address.
|
|
344
|
+
signInAddressOf: (ctx) => ctx.user?.signIn ?? null,
|
|
345
|
+
// MCP elicitation, when the client supports it; null otherwise (then `confirm: true` is needed).
|
|
346
|
+
elicit: (ctx, request) => (ctx.canElicit ? ctx.elicit(request) : null),
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// tools/list: kit.definitions() tools/call: kit.call(name, args, ctx)
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
The descriptions tell the assistant to run a tool only when the person asks, and `use_invite_code`
|
|
353
|
+
never saves a code a document, a page or another tool put in the conversation without the person's
|
|
354
|
+
own yes. The whole guide, with both tools mounted with the official TypeScript and Python SDKs, the
|
|
355
|
+
store rules and the defences against prompt injection:
|
|
356
|
+
https://boltclaw.ai/docs/referral-ai-assistants
|
|
357
|
+
|
|
358
|
+
## Keeping the referral code through Safari
|
|
359
|
+
|
|
360
|
+
Safari deletes a cookie a script created once the visitor has gone 7 days of browsing without
|
|
361
|
+
using your site, whatever your program's window is, and keeps one only 24 hours on a page reached
|
|
362
|
+
through a tracking link. A first-party cookie set by your own server keeps the lifetime it was given,
|
|
363
|
+
unless the response comes through a third party's CNAME or IP address, which Safari caps at 7 days
|
|
364
|
+
(https://webkit.org/tracking-prevention/, read 2026-10-23). The
|
|
365
|
+
middleware reads `?via=<code>` on your server and sets the same `bc_ref` cookie the website tag
|
|
366
|
+
reads, so the two always agree.
|
|
367
|
+
|
|
368
|
+
Next.js 16 (`proxy.ts`; on Next.js 15 and earlier the same in `middleware.ts`):
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
import { NextResponse, type NextFetchEvent, type NextRequest } from "next/server";
|
|
372
|
+
import { nextReferralsProxy } from "@boltclaw/referrals/server";
|
|
373
|
+
|
|
374
|
+
const referrals = nextReferralsProxy({
|
|
375
|
+
cookieDays: 30, // your program's cookie window
|
|
376
|
+
attribution: "last_click", // or "first_click"
|
|
377
|
+
params: ["via", "ref"], // every query name your links use
|
|
378
|
+
programs: { friend: process.env.BOLTCLAW_FRIEND_PROGRAM_ID! }, // a name that belongs to one program
|
|
379
|
+
// Optional: count each visit as a click (a key with conversions:write). Never delays the response.
|
|
380
|
+
reportClicks: { apiKey: process.env.BOLTCLAW_API_KEY! },
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
export function proxy(request: NextRequest, event: NextFetchEvent) {
|
|
384
|
+
const response = NextResponse.next();
|
|
385
|
+
referrals(request, response, event); // event.waitUntil keeps the click report alive
|
|
386
|
+
return response;
|
|
387
|
+
}
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
`params` lists every query name your links use, read in that order. A name in `programs` belongs
|
|
391
|
+
to one program when one product runs several: its code is kept in a cookie of its own,
|
|
392
|
+
`bc_ref_<name>` (with `bc_sub_<name>`), so a friend's code never replaces a partner's, and
|
|
393
|
+
`referralCodeFor(cookieHeader, programId, { programs })` reads it back on your server. Each code a
|
|
394
|
+
request carries is recorded once, as a click, with the visitor's address taken from the LAST
|
|
395
|
+
`X-Forwarded-For` entry past your own private proxies (Vercel overwrites the header and Cloudflare
|
|
396
|
+
appends to it), never the first, which the visitor writes; or from a header your host overwrites,
|
|
397
|
+
when you name it (`ipHeader: "x-real-ip"`, `"cf-connecting-ip"`, `"fly-client-ip"`). No header is
|
|
398
|
+
believed unless you name it. A code kept for a program is the code as it arrived: Bolt Claw credits
|
|
399
|
+
it only in the program it belongs to, so send its conversions with that program's id.
|
|
400
|
+
|
|
401
|
+
Express:
|
|
402
|
+
|
|
403
|
+
```ts
|
|
404
|
+
import { expressReferralsMiddleware } from "@boltclaw/referrals/server";
|
|
405
|
+
|
|
406
|
+
app.set("trust proxy", true); // behind a proxy, so req.ip is the visitor's
|
|
407
|
+
app.use(expressReferralsMiddleware({ cookieDays: 30, params: ["via"] }));
|
|
408
|
+
// res.locals.boltclawReferral.current is the code that counts after this request.
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
The cookie is `bc_ref=<code>; Path=/; Max-Age=<days>; SameSite=Lax`, plus `Secure` on https. It is
|
|
412
|
+
not HttpOnly, on purpose: the tag reads it. Without `params`, `param` and `acceptParams: ["ref",
|
|
413
|
+
"fpr"]` read the parameters the tag's `data-param` and `data-accept-params` name, as before. `bc_click` and
|
|
414
|
+
`bc_sub` are handed back on the result and stored nowhere. Any other framework:
|
|
415
|
+
`referralsMiddleware(options)({ url, cookie, secure, ip?, userAgent?, referrer?, country? })` returns
|
|
416
|
+
the `Set-Cookie` value to send.
|
|
417
|
+
|
|
418
|
+
## Verifying webhooks
|
|
419
|
+
|
|
420
|
+
```ts
|
|
421
|
+
import { verifyWebhook, WebhookVerificationError } from "@boltclaw/referrals/server";
|
|
422
|
+
|
|
423
|
+
export async function POST(request: Request) {
|
|
424
|
+
const body = await request.text(); // the RAW body, before any JSON parsing
|
|
425
|
+
try {
|
|
426
|
+
const event = await verifyWebhook(body, request.headers, process.env.BOLTCLAW_WEBHOOK_SECRET!);
|
|
427
|
+
if (event.test) return new Response(null, { status: 204 }); // a test delivery
|
|
428
|
+
// ... handle event.event
|
|
429
|
+
} catch (error) {
|
|
430
|
+
if (error instanceof WebhookVerificationError) return new Response(null, { status: 400 });
|
|
431
|
+
throw error;
|
|
432
|
+
}
|
|
433
|
+
return new Response(null, { status: 204 });
|
|
434
|
+
}
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
It checks `X-BoltClaw-Signature` (HMAC-SHA256 of `{timestamp}.{raw body}` under the endpoint's
|
|
438
|
+
signing secret) in constant time and refuses a timestamp more than 300 seconds away
|
|
439
|
+
(`toleranceSeconds` to change it). `error.reason` is `missing_header`, `stale`, `bad_signature` or
|
|
440
|
+
`bad_body`.
|
|
441
|
+
|
|
442
|
+
## Test mode
|
|
443
|
+
|
|
444
|
+
`test: true` on any conversion records it as TEST data: credited to the program's test partner, never
|
|
445
|
+
payable, never counted toward a limit or a total, emailing nobody, and delivered only to webhook
|
|
446
|
+
endpoints that switched test events on (each test delivery carries `"test": true`). A test
|
|
447
|
+
`eventId` never collides with a live one. `sendTestSale()` runs a whole one; the dashboard's "Clear
|
|
448
|
+
test data" (or `DELETE /v1/referrals/programs/{id}/test-data`) deletes them.
|
|
449
|
+
|
|
450
|
+
## Security model
|
|
451
|
+
|
|
452
|
+
- **Mint tokens on your server.** The page never needs your API key, only a token for one user.
|
|
453
|
+
- **Tokens last 1 hour by default** (60 seconds to 24 hours with `ttlSeconds`). When one runs out
|
|
454
|
+
the panel emits `expired`; mint another and call `update({ token })`.
|
|
455
|
+
- **List your origins** in the dashboard. The panel can only be framed by sites on that list.
|
|
456
|
+
- **The token never goes in a URL or a log.** The loader puts it in the URL fragment (never sent to
|
|
457
|
+
a server or in a Referer); the browser client sends it in an `Authorization` header. Do not log it.
|
|
458
|
+
- **An API key never reaches a browser.** `ReferralsServer` refuses to start where `window` exists.
|
|
459
|
+
Errors it throws never contain the key.
|
|
460
|
+
- The panel makes no request to any host but Bolt Claw. A friend's email typed into "send from my
|
|
461
|
+
own system" is handed to your page as an `inviteRequested` event and goes nowhere else.
|
|
462
|
+
- Messages from the panel are accepted only from its own frame and origin, in a fixed shape.
|
|
463
|
+
- **The browser never decides a discount.** The public offer read (`useOffer`) sends no cookie and
|
|
464
|
+
no credential and only shows words; `getOffer` on your server decides what Stripe applies.
|
|
465
|
+
|
|
466
|
+
## Events
|
|
467
|
+
|
|
468
|
+
The handler receives the payload without `source`, `version` and `type`.
|
|
469
|
+
|
|
470
|
+
| Event | Payload | When |
|
|
471
|
+
| --- | --- | --- |
|
|
472
|
+
| `ready` | none | The first View is drawn. |
|
|
473
|
+
| `resize` | `{ height }` (100 to 4000) | The content height changed; inline frames resize themselves. |
|
|
474
|
+
| `copied` | `{ what: "link" }` | The referrer copied their link. |
|
|
475
|
+
| `shared` | `{ channel }` | A share button, copy or QR was used. |
|
|
476
|
+
| `expired` | none | The token is no longer valid; call `update({ token })`. |
|
|
477
|
+
| `closed` | none | The modal closed. |
|
|
478
|
+
| `error` | `{ code }` | A short stable code. |
|
|
479
|
+
| `inviteRequested` | `{ email }` | Only when the program allows inviting from your own system. |
|
|
480
|
+
| `dismissed` | none | "Not right now" on the thank-you card, already recorded. Hide the card. |
|
|
481
|
+
|
|
482
|
+
## Errors
|
|
483
|
+
|
|
484
|
+
Every failed call throws `ReferralsApiError` with `status`, `code`, `message`, `requestId`, and, on
|
|
485
|
+
a 428, `approvalId` and `approvalUrl`. Match on `code`. `error.isExpired` / `isExpired(error)` is true
|
|
486
|
+
for an expired panel token.
|
|
487
|
+
|
|
488
|
+
| Status | `code` | Meaning |
|
|
489
|
+
| --- | --- | --- |
|
|
490
|
+
| 0 | `network_error` | The request could not be sent. |
|
|
491
|
+
| 400 | `validation_error` and others | The input was refused; the message says why. |
|
|
492
|
+
| 401 | `token_expired` (panel) | Mint a new token. For the server API, a bad or revoked key. |
|
|
493
|
+
| 403 | `INSUFFICIENT_SCOPE` and others | The key lacks the scope. |
|
|
494
|
+
| 409 | `CONFLICT` | For example, choosing a code for an existing referrer. |
|
|
495
|
+
| 428 | `APPROVAL_REQUIRED` | A person must approve at `approvalUrl`; repeat with `approvalId`. |
|
|
496
|
+
| 429 | `rate_limited` | Slow down. |
|
|
497
|
+
|
|
498
|
+
## License
|
|
499
|
+
|
|
500
|
+
MIT
|