@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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +500 -3
  3. package/dist/api-types.d.ts +245 -0
  4. package/dist/api-types.js +2 -0
  5. package/dist/api-types.js.map +1 -0
  6. package/dist/assistant-tools.d.ts +40 -0
  7. package/dist/assistant-tools.js +120 -0
  8. package/dist/assistant-tools.js.map +1 -0
  9. package/dist/assistant.d.ts +62 -0
  10. package/dist/assistant.js +305 -0
  11. package/dist/assistant.js.map +1 -0
  12. package/dist/case.d.ts +2 -0
  13. package/dist/case.js +30 -0
  14. package/dist/case.js.map +1 -0
  15. package/dist/client.d.ts +22 -0
  16. package/dist/client.js +80 -0
  17. package/dist/client.js.map +1 -0
  18. package/dist/element.d.ts +4 -0
  19. package/dist/element.js +204 -0
  20. package/dist/element.js.map +1 -0
  21. package/dist/errors.d.ts +19 -0
  22. package/dist/errors.js +62 -0
  23. package/dist/errors.js.map +1 -0
  24. package/dist/generated/api.d.ts +6477 -0
  25. package/dist/generated/api.js +891 -0
  26. package/dist/generated/api.js.map +1 -0
  27. package/dist/index.d.ts +18 -0
  28. package/dist/index.js +10 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/loader.d.ts +118 -0
  31. package/dist/loader.js +524 -0
  32. package/dist/loader.js.map +1 -0
  33. package/dist/middleware.d.ts +100 -0
  34. package/dist/middleware.js +378 -0
  35. package/dist/middleware.js.map +1 -0
  36. package/dist/origin.d.ts +3 -0
  37. package/dist/origin.js +22 -0
  38. package/dist/origin.js.map +1 -0
  39. package/dist/react.d.ts +69 -0
  40. package/dist/react.js +328 -0
  41. package/dist/react.js.map +1 -0
  42. package/dist/server-index.d.ts +17 -0
  43. package/dist/server-index.js +9 -0
  44. package/dist/server-index.js.map +1 -0
  45. package/dist/server.d.ts +106 -0
  46. package/dist/server.js +511 -0
  47. package/dist/server.js.map +1 -0
  48. package/dist/typed-code.d.ts +78 -0
  49. package/dist/typed-code.js +234 -0
  50. package/dist/typed-code.js.map +1 -0
  51. package/dist/types.d.ts +254 -0
  52. package/dist/types.js +61 -0
  53. package/dist/types.js.map +1 -0
  54. package/dist/webhooks.d.ts +20 -0
  55. package/dist/webhooks.js +90 -0
  56. package/dist/webhooks.js.map +1 -0
  57. 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
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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