@genesis-tech/genesispay-seller 0.8.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 (60) hide show
  1. package/CHANGELOG.md +330 -0
  2. package/LICENSE +21 -0
  3. package/README.md +429 -0
  4. package/dist/client.d.ts +275 -0
  5. package/dist/client.d.ts.map +1 -0
  6. package/dist/client.js +541 -0
  7. package/dist/client.js.map +1 -0
  8. package/dist/customers.d.ts +41 -0
  9. package/dist/customers.d.ts.map +1 -0
  10. package/dist/customers.js +76 -0
  11. package/dist/customers.js.map +1 -0
  12. package/dist/errors.d.ts +63 -0
  13. package/dist/errors.d.ts.map +1 -0
  14. package/dist/errors.js +162 -0
  15. package/dist/errors.js.map +1 -0
  16. package/dist/index.d.ts +13 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +13 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/invoices.d.ts +80 -0
  21. package/dist/invoices.d.ts.map +1 -0
  22. package/dist/invoices.js +133 -0
  23. package/dist/invoices.js.map +1 -0
  24. package/dist/mandate-gate.d.ts +49 -0
  25. package/dist/mandate-gate.d.ts.map +1 -0
  26. package/dist/mandate-gate.js +109 -0
  27. package/dist/mandate-gate.js.map +1 -0
  28. package/dist/mandates.d.ts +172 -0
  29. package/dist/mandates.d.ts.map +1 -0
  30. package/dist/mandates.js +213 -0
  31. package/dist/mandates.js.map +1 -0
  32. package/dist/networks.d.ts +10 -0
  33. package/dist/networks.d.ts.map +1 -0
  34. package/dist/networks.js +22 -0
  35. package/dist/networks.js.map +1 -0
  36. package/dist/payment-gate.d.ts +67 -0
  37. package/dist/payment-gate.d.ts.map +1 -0
  38. package/dist/payment-gate.js +106 -0
  39. package/dist/payment-gate.js.map +1 -0
  40. package/dist/peerpay-settlement.d.ts +27 -0
  41. package/dist/peerpay-settlement.d.ts.map +1 -0
  42. package/dist/peerpay-settlement.js +100 -0
  43. package/dist/peerpay-settlement.js.map +1 -0
  44. package/dist/plans.d.ts +68 -0
  45. package/dist/plans.d.ts.map +1 -0
  46. package/dist/plans.js +127 -0
  47. package/dist/plans.js.map +1 -0
  48. package/dist/resource.d.ts +47 -0
  49. package/dist/resource.d.ts.map +1 -0
  50. package/dist/resource.js +71 -0
  51. package/dist/resource.js.map +1 -0
  52. package/dist/usdc-amount.d.ts +7 -0
  53. package/dist/usdc-amount.d.ts.map +1 -0
  54. package/dist/usdc-amount.js +21 -0
  55. package/dist/usdc-amount.js.map +1 -0
  56. package/dist/webhooks.d.ts +277 -0
  57. package/dist/webhooks.d.ts.map +1 -0
  58. package/dist/webhooks.js +223 -0
  59. package/dist/webhooks.js.map +1 -0
  60. package/package.json +58 -0
package/README.md ADDED
@@ -0,0 +1,429 @@
1
+ # @genesis-tech/genesispay-seller
2
+
3
+ Framework-agnostic x402 payment gate for sellers, powered by GenesisPay (formerly PeerDirect).
4
+
5
+ Wrap any Web-standard `(Request) => Response` handler and it becomes a paid
6
+ endpoint:
7
+
8
+ - Requests without payment get `402 Payment Required` with an x402 V2
9
+ `PAYMENT-REQUIRED` header (and JSON body) describing the USDC payment.
10
+ - Requests carrying a `PAYMENT-SIGNATURE` header are structurally validated
11
+ against the requirement, settled via your `verifySettlement` hook, and — on
12
+ success — your handler runs and its response carries a `PAYMENT-RESPONSE`
13
+ header with the settlement receipt (tx hash, payer, amount).
14
+
15
+ Works with Next.js route handlers, Hono, Bun.serve, and anything else that
16
+ speaks the Fetch API.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm install @genesis-tech/genesispay-seller
22
+ ```
23
+
24
+ ## Quick start (legacy `PeerPay` client — recommended)
25
+
26
+ The `PeerPay` client configures from **just the seller API key**. The key prefix
27
+ (`pp_sk_test_…` / `pp_sk_live_…`) determines the mode and base URL, and the payout
28
+ wallet + network are resolved from the key via the GenesisPay backend and cached.
29
+
30
+ ```ts
31
+ import { PeerPay } from "@genesis-tech/genesispay-seller";
32
+
33
+ const peerpay = new PeerPay({ apiKey: process.env.PEERPAY_SELLER_API_KEY! });
34
+
35
+ // Human hosted checkout — the wallet is defaulted server-side from the key.
36
+ // `metadata` and `clientReferenceId` come back on retrieve() and on the webhook,
37
+ // so you never need your own table just to map a link back to a buyer:
38
+ const { publicId, payUrl } = await peerpay.checkout.create({
39
+ title: "50 credits",
40
+ amount: "5.00", // decimal string in `asset` — see "Amounts and assets" below
41
+ clientReferenceId: order.id,
42
+ metadata: { buyerId: user.id, plan: "starter" },
43
+ returnUrl: "https://shop.example.com/thanks",
44
+ cancelUrl: "https://shop.example.com/cart",
45
+ });
46
+
47
+ // Poll for settlement:
48
+ const session = await peerpay.checkout.retrieve(publicId);
49
+ if (session.paid) fulfil(session.clientReferenceId);
50
+ // An unknown id throws PeerPayNotFoundError, not PeerPayConfigError — so a
51
+ // typo'd id is distinguishable from a broken key or an unreachable backend.
52
+
53
+ // Agent x402 gate — payTo + network resolved from the key, settlement auto-wired:
54
+ export const GET = peerpay.gate({ amountUsdc: "0.02" }).wrap(
55
+ async () => Response.json({ data: "the good stuff" }),
56
+ );
57
+ ```
58
+
59
+ ### Amounts and settlement
60
+
61
+ During the beta, every new hosted checkout link settles in **USDC on Base**. An
62
+ amount is therefore a decimal dollar string — never a number:
63
+
64
+ ```ts
65
+ // 5 USDC / 5 dollars:
66
+ await peerpay.checkout.create({ title: "Credits", amount: "5.00" });
67
+ ```
68
+
69
+ Buyers can use EUR or USD in the Privy/MoonPay flow; MoonPay converts the local
70
+ fiat amount to USDC before settlement. The provider shows the final live quote
71
+ and fees. New `asset: "EURC"` requests are rejected during the beta; historical
72
+ EURC links remain readable.
73
+
74
+ `amountUsdc` is the **deprecated** pre-0.6.0 name for `amount`. It still works
75
+ everywhere `amount` does, on input and on output, so no existing integration
76
+ breaks.
77
+
78
+ You may pass both, and the SDK sends both on the wire so a backend older than
79
+ 0.6.0 still finds the field it knows. Passing **two different amounts** throws
80
+ `PeerPayValidationError` before the request leaves your process — a link for
81
+ money you did not mean must not be created because one field quietly won.
82
+
83
+ `gate({ amountUsdc })` keeps its name deliberately: an x402 gate prices a request
84
+ in USDC on Base, so there the currency really is part of the field.
85
+
86
+ ### Webhooks
87
+
88
+ Prefer a webhook over polling. Verification is one call — it checks the HMAC in
89
+ constant time and enforces a replay window on the signature timestamp:
90
+
91
+ ```ts
92
+ import { constructEvent, PeerPaySignatureVerificationError } from "@genesis-tech/genesispay-seller";
93
+
94
+ export async function POST(request: Request) {
95
+ const rawBody = await request.text(); // raw — never re-serialize before verifying
96
+ try {
97
+ const event = await constructEvent(
98
+ rawBody,
99
+ request.headers.get("PEERPAY-SIGNATURE") ?? "",
100
+ process.env.PEERPAY_WEBHOOK_SECRET!,
101
+ );
102
+ if (event.type === "payment.confirmed") await fulfil(event.data);
103
+ return new Response(null, { status: 204 });
104
+ } catch (error) {
105
+ if (error instanceof PeerPaySignatureVerificationError) {
106
+ return new Response("invalid signature", { status: 400 });
107
+ }
108
+ throw error;
109
+ }
110
+ }
111
+ ```
112
+
113
+ `constructEvent` is **async** — unlike Stripe's synchronous equivalent. It is built
114
+ on WebCrypto rather than `node:crypto` so the SDK also runs on Edge, Workers and
115
+ Bun. It is available as a free function (a webhook route rarely has a client in
116
+ scope) and as `peerpay.webhooks.constructEvent(…)`. Default replay tolerance is
117
+ 300 s in both directions; override with `{ toleranceSeconds }`. Multiple `v1=`
118
+ values in the header are all checked, so you can rotate an endpoint secret without
119
+ dropping deliveries.
120
+
121
+ **Read `event.data.link.asset` before you book the money.** The payment payload
122
+ carries `amount` (a decimal string) and the `asset` it is denominated in — euros
123
+ on an EURC link, dollars on a USDC one. `amountUsdc` is the deprecated alias carrying the identical value;
124
+ on an EURC link its name is simply wrong, which is why `amount` replaced it.
125
+ `amountUsdcMinor` keeps its name and its meaning: the integer minor units of that
126
+ same amount.
127
+
128
+ ```ts
129
+ if (event.type === "payment.confirmed") {
130
+ const { asset, amount, amountUsdcMinor } = event.data.link;
131
+ // `asset` is typed optional: a delivery enqueued before 0.7.0 is retried
132
+ // verbatim and arrives without one. Do not default it — guessing "USDC" on a
133
+ // euro payment is the bug this field removes. A missing `asset` is genuinely
134
+ // unbookable; that is why it, and not `amount`, is the guard.
135
+ if (!asset) return ack("asset missing — resolving via checkout.retrieve");
136
+ await recordRevenue({ currency: asset, amount, minorUnits: BigInt(amountUsdcMinor) });
137
+ }
138
+ ```
139
+
140
+ `event.data.attempt.chainId` is the chain `attempt.txHash` is on — read the two
141
+ together, since the same hash resolves to nothing on the wrong chain. It is on
142
+ the *attempt*, not the link, because a pay-by-bank settlement mints on whatever
143
+ chain the provider uses, which need not be the link's.
144
+
145
+ A missing `chainId` is **not** the same problem as a missing `asset` — the amount
146
+ and currency are still fully determined — but what to do about it depends on you.
147
+ If your endpoint only ever receives one chain, book the payment and skip the
148
+ explorer link. If it can receive more than one, resolve through
149
+ `checkout.retrieve` before booking: an amount whose chain you cannot name is
150
+ exactly what this field exists to prevent you from mis-booking.
151
+
152
+ Either way, do not default it, and mind the polarity: `undefined !== 8453` is
153
+ `true`, so `if (chainId !== 8453)` reads a missing chain as testnet and its
154
+ mirror reads it as mainnet. Branch on presence first. And do not assume the set
155
+ is Base-only — a pay-by-bank settlement reports the provider's chain, which can
156
+ be Gnosis, Polygon or Ethereum.
157
+
158
+ **One settlement can reach you twice — handle both cases separately.**
159
+
160
+ - *Retries*: a delivery is attempted up to three times on a non-2xx response, and
161
+ `event.id` is stable across those attempts. Dedupe on `event.id` before fulfilling.
162
+ - *The event pair*: a **single-use** link emits both `payment.confirmed` and
163
+ `link.paid` for the same settlement. These are two distinct deliveries with two
164
+ **different** `event.id`s, so `event.id` will not collapse them — branch on
165
+ `event.type` and fulfil on one of them. Reusable links emit only
166
+ `payment.confirmed`.
167
+
168
+ ### Invoices
169
+
170
+ Invoices are one-off commercial billing documents. Create or reuse a customer,
171
+ build a draft, then finalize it. Finalization freezes the billing details and
172
+ creates exactly one single-use payment link; a draft cannot be paid or emailed.
173
+
174
+ ```ts
175
+ const customer = await peerpay.customers.create({
176
+ name: "Ada Lovelace",
177
+ email: "ada@example.com",
178
+ companyName: "Analytical Engines Ltd",
179
+ countryCode: "GB",
180
+ });
181
+
182
+ const draft = await peerpay.invoices.create({
183
+ customerId: customer.publicId,
184
+ asset: "EURC",
185
+ dueAt: "2026-08-31T23:59:59.999Z",
186
+ taxBps: 2_300, // 23%; manual rate, not automated tax advice
187
+ lineItems: [
188
+ { description: "Consulting", quantity: 2, unitAmount: "450.00" },
189
+ ],
190
+ });
191
+
192
+ const invoice = await peerpay.invoices.finalize(draft.publicId);
193
+ await peerpay.invoices.send(invoice.publicId, {
194
+ idempotencyKey: `invoice-${invoice.publicId}-initial`,
195
+ });
196
+
197
+ invoice.hostedInvoiceUrl; // customer-facing document
198
+ invoice.pdfUrl; // printable PDF
199
+ invoice.payment.payUrl; // canonical GenesisPay checkout
200
+ ```
201
+
202
+ All invoice money fields ending in `Minor` are integer strings. `paid` is a
203
+ read-only projection of a confirmed, non-simulated GenesisPay payment; it cannot
204
+ be set through the SDK. Finalized invoices cannot be edited. Use
205
+ `invoices.void()` to cancel collection or `invoices.markUncollectible()` to write
206
+ off an unpaid invoice.
207
+
208
+ ### Subscriptions
209
+
210
+ Plans are the reusable template you create once and hand to any number of
211
+ customers. You do **not** need your own billing cron — renewals run on our
212
+ scheduler.
213
+
214
+ ```ts
215
+ const plan = await peerpay.plans.create({
216
+ title: "Pro",
217
+ amountPerPeriod: "9.00",
218
+ periodDays: 30,
219
+ });
220
+
221
+ // Send the customer to the hosted subscription checkout:
222
+ redirect(plan.checkoutUrl);
223
+ ```
224
+
225
+ The customer signs one permit there; after that, renewals are charged without
226
+ further signatures and emit `subscription.renewed` (or `subscription.past_due`).
227
+ `peerpay.mandates.*` exposes the per-payer authorizations underneath —
228
+ `create`, `retrieve`, `charge` (per-use metering) and `revoke`.
229
+
230
+ There is deliberately **no `mandates.activate`**: activation requires the payer's
231
+ own signature over the permit, so it belongs in the payer's frontend, not in a
232
+ server holding your secret key.
233
+
234
+ **Cancelling a subscription** — list the plan's subscribers, find the payer, revoke:
235
+
236
+ ```ts
237
+ let startingAfter: string | undefined;
238
+ let hasMore = true;
239
+ while (hasMore) {
240
+ const page = await peerpay.mandates.list({
241
+ planId: plan.publicId,
242
+ status: "active",
243
+ startingAfter,
244
+ });
245
+ const match = page.mandates.find(
246
+ (m) => m.payerWallet.toLowerCase() === wallet.toLowerCase(),
247
+ );
248
+ if (match) return peerpay.mandates.revoke(match.id);
249
+ startingAfter = page.mandates.at(-1)?.id;
250
+ hasMore = page.hasMore;
251
+ }
252
+ ```
253
+
254
+ Pagination is keyset-based: `startingAfter` is the id of the last mandate on the
255
+ previous page. Revoking is idempotent — a second call changes nothing and emits
256
+ no second `mandate.revoked`.
257
+
258
+ Note: failed renewals are reported as events, but there is no automatic dunning
259
+ (retry escalation, grace periods) yet — build that on
260
+ `subscription.past_due` / `mandate.charge_failed` for now.
261
+
262
+ ### Testing the paid path
263
+
264
+ ```ts
265
+ const session = await peerpay.checkout.simulatePayment(publicId);
266
+ // session.paid === true, and the real webhooks have fired.
267
+ ```
268
+
269
+ Test keys only — a `pp_sk_live_…` key gets a 403, and on a mainnet deployment the
270
+ endpoint does not exist at all. The resulting attempt has `txHash: null` (no
271
+ transaction happened) and `simulated: true`; branch on that flag rather than on
272
+ the missing hash, because a *pending real* attempt has no hash either.
273
+
274
+ ### Redirect parameters are not proof of payment
275
+
276
+ The hosted checkout appends `?peerpay_link_id=…&peerpay_status=paid` to your
277
+ `returnUrl`. Those parameters are a **UI hint only** — they are not signed, and a
278
+ payer can navigate to that URL directly without paying. Fulfil on the
279
+ `payment.confirmed` webhook or on `checkout.retrieve(publicId).paid`, never on the
280
+ query string.
281
+
282
+ ### Limits
283
+
284
+ | Field | Limit |
285
+ |---|---|
286
+ | `metadata` | 20 keys; keys ≤ 40 chars; values must be strings, ≤ 500 chars; ≤ 4096 bytes serialized |
287
+ | `clientReferenceId` | ≤ 200 chars |
288
+ | `returnUrl` / `cancelUrl` | ≤ 2048 chars, `https` only (`http` allowed for localhost) |
289
+
290
+ Exceeding any of these fails the `checkout.create` call server-side with a 422.
291
+
292
+ ### Note on `paid` for reusable links
293
+
294
+ `session.paid` is derived from `confirmedPaymentCount > 0`. For a `reusable` link
295
+ that counter only ever grows, so `paid` stays `true` from the first payment
296
+ onward — it answers "has this link ever been paid", not "has *this* buyer paid".
297
+ For per-buyer fulfilment on a reusable link, use a webhook, or track
298
+ `confirmedPaymentCount` as a delta.
299
+
300
+ Options: `baseUrl` (override the mode default — https, http only for localhost;
301
+ both modes default to the GenesisPay facilitator, so it is optional), `configTtlMs`
302
+ (seller-config cache TTL, default 5 min), `expectedPayTo` (recommended for `live`
303
+ keys — a local pin that fail-closes if the resolved wallet ever differs), `fetchFn`.
304
+ The client fails **closed**: an unreachable backend returns `503` and a seller with
305
+ no wallet returns a `402` `payment_not_configured` — the paid handler never runs
306
+ without a valid destination. It also refuses to advertise a wallet whose network
307
+ doesn't match the SDK's native-USDC table or the key mode.
308
+
309
+ The low-level `createPaymentGate` / `peerPaySettlement` primitives below remain
310
+ available for advanced cases (custom wallet/network per gate, self-hosting).
311
+
312
+ ## Quick start (Next.js route handler)
313
+
314
+ ```ts
315
+ // app/api/premium/route.ts
316
+ import { createPaymentGate, peerPaySettlement } from "@genesis-tech/genesispay-seller";
317
+
318
+ const gate = createPaymentGate({
319
+ amountUsdc: "0.10",
320
+ payTo: "0xYourWalletAddress",
321
+ description: "Premium market data",
322
+ network: "base-sepolia", // or "base" for mainnet
323
+ });
324
+
325
+ const verifySettlement = peerPaySettlement({
326
+ facilitatorBaseUrl: "https://your-peerpay-instance.example",
327
+ apiKey: process.env.PEERPAY_SELLER_KEY!, // pp_sk_...
328
+ });
329
+
330
+ export const GET = gate.wrap(
331
+ async () => Response.json({ data: "the good stuff" }),
332
+ { verifySettlement },
333
+ );
334
+ ```
335
+
336
+ Next.js route context (`{ params }`) is passed through to your handler
337
+ untouched, so dynamic routes work as usual:
338
+
339
+ ```ts
340
+ export const GET = gate.wrap(
341
+ async (request, { params }: { params: Promise<{ id: string }> }) => {
342
+ const { id } = await params;
343
+ return Response.json({ id });
344
+ },
345
+ { verifySettlement },
346
+ );
347
+ ```
348
+
349
+ ## Quick start (Hono)
350
+
351
+ ```ts
352
+ import { Hono } from "hono";
353
+ import { createPaymentGate, peerPaySettlement } from "@genesis-tech/genesispay-seller";
354
+
355
+ const gate = createPaymentGate({
356
+ amountUsdc: "0.05",
357
+ payTo: "0xYourWalletAddress",
358
+ description: "Paid API call",
359
+ network: "base-sepolia",
360
+ });
361
+
362
+ const gated = gate.wrap(
363
+ async () => Response.json({ ok: true }),
364
+ {
365
+ verifySettlement: peerPaySettlement({
366
+ facilitatorBaseUrl: "https://your-peerpay-instance.example",
367
+ apiKey: process.env.PEERPAY_SELLER_KEY!,
368
+ }),
369
+ },
370
+ );
371
+
372
+ const app = new Hono();
373
+ app.get("/premium", (c) => gated(c.req.raw));
374
+
375
+ export default app;
376
+ ```
377
+
378
+ The same wrapped handler drops straight into `Bun.serve({ fetch: gated })`.
379
+
380
+ ## Configuration
381
+
382
+ `createPaymentGate(config)`:
383
+
384
+ | Option | Required | Description |
385
+ | --- | --- | --- |
386
+ | `amountUsdc` | yes | Decimal USDC amount, e.g. `"0.10"` (max 6 decimals). |
387
+ | `payTo` | yes | EVM wallet address that receives the USDC. |
388
+ | `description` | no | Shown to payers in the payment requirement. |
389
+ | `network` | no | `"base-sepolia"` (default) or `"base"`. |
390
+ | `resource` | no | Canonical resource URL. Defaults to the request URL (query stripped). |
391
+ | `mimeType` | no | MIME type of the paid resource (default `application/json`). |
392
+ | `maxTimeoutSeconds` | no | Advertised authorization validity window (default 300). |
393
+
394
+ `gate.wrap(handler, { verifySettlement })` requires a settlement hook. Use the
395
+ built-in `peerPaySettlement({ facilitatorBaseUrl, apiKey })`, which POSTs the
396
+ payment to GenesisPay's facilitator (`/api/v1/facilitator/settle`). GenesisPay
397
+ broadcasts the EIP-3009 authorization on-chain, verifies the USDC transfer,
398
+ and returns the receipt. `facilitatorBaseUrl` is optional and defaults to the
399
+ public development facilitator (`DEFAULT_FACILITATOR_BASE_URL`,
400
+ `https://peerpay-app-development.up.railway.app`), so you can omit it in dev —
401
+ set it explicitly for production. Or supply your own hook:
402
+
403
+ ```ts
404
+ import type { VerifySettlement } from "@genesis-tech/genesispay-seller";
405
+
406
+ const verifySettlement: VerifySettlement = async ({ payment, requirement }) => {
407
+ // settle + verify however you like, then:
408
+ return {
409
+ ok: true,
410
+ settlement: {
411
+ success: true,
412
+ transaction: "0x...",
413
+ network: requirement.network,
414
+ amount: requirement.maxAmountRequired,
415
+ payer: payment.payload.authorization.from,
416
+ },
417
+ };
418
+ };
419
+ ```
420
+
421
+ ## Notes
422
+
423
+ - Amounts are handled as integer USDC minor units (6 decimals) internally —
424
+ never floats.
425
+ - The gate performs structural validation only (amount, destination, network,
426
+ validity window). Actual money movement and on-chain verification happen in
427
+ your `verifySettlement` hook.
428
+ - Get a seller API key (`pp_sk_...`) from your GenesisPay dashboard under
429
+ Developers.