@genesis-tech/genesispay-seller 0.13.2 → 1.5.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/CHANGELOG.md +227 -0
- package/README.md +719 -91
- package/dist/checkout-documents.d.ts +105 -0
- package/dist/checkout-documents.d.ts.map +1 -0
- package/dist/checkout-documents.js +171 -0
- package/dist/checkout-documents.js.map +1 -0
- package/dist/checkout-return.d.ts +5 -5
- package/dist/checkout-return.d.ts.map +1 -1
- package/dist/checkout-return.js +3 -2
- package/dist/checkout-return.js.map +1 -1
- package/dist/client.d.ts +57 -7
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +100 -10
- package/dist/client.js.map +1 -1
- package/dist/contract-mandates.d.ts +39 -0
- package/dist/contract-mandates.d.ts.map +1 -0
- package/dist/contract-mandates.js +98 -0
- package/dist/contract-mandates.js.map +1 -0
- package/dist/contract-usage.d.ts +29 -0
- package/dist/contract-usage.d.ts.map +1 -0
- package/dist/contract-usage.js +92 -0
- package/dist/contract-usage.js.map +1 -0
- package/dist/errors.d.ts +71 -5
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +124 -13
- package/dist/errors.js.map +1 -1
- package/dist/fulfillment-attempts.d.ts +29 -0
- package/dist/fulfillment-attempts.d.ts.map +1 -0
- package/dist/fulfillment-attempts.js +71 -0
- package/dist/fulfillment-attempts.js.map +1 -0
- package/dist/fulfillment.d.ts +152 -0
- package/dist/fulfillment.d.ts.map +1 -0
- package/dist/fulfillment.js +810 -0
- package/dist/fulfillment.js.map +1 -0
- package/dist/genesispay-settlement.d.ts +4 -2
- package/dist/genesispay-settlement.d.ts.map +1 -1
- package/dist/genesispay-settlement.js +412 -15
- package/dist/genesispay-settlement.js.map +1 -1
- package/dist/index.d.ts +11 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -4
- package/dist/index.js.map +1 -1
- package/dist/invoices.d.ts +31 -0
- package/dist/invoices.d.ts.map +1 -1
- package/dist/invoices.js +61 -2
- package/dist/invoices.js.map +1 -1
- package/dist/item-tax.d.ts +25 -0
- package/dist/item-tax.d.ts.map +1 -0
- package/dist/item-tax.js +43 -0
- package/dist/item-tax.js.map +1 -0
- package/dist/mandate-gate.d.ts +6 -1
- package/dist/mandate-gate.d.ts.map +1 -1
- package/dist/mandate-gate.js +81 -7
- package/dist/mandate-gate.js.map +1 -1
- package/dist/mandates.d.ts +21 -4
- package/dist/mandates.d.ts.map +1 -1
- package/dist/mandates.js +24 -3
- package/dist/mandates.js.map +1 -1
- package/dist/networks.d.ts.map +1 -1
- package/dist/networks.js +5 -4
- package/dist/networks.js.map +1 -1
- package/dist/payment-gate.d.ts +32 -1
- package/dist/payment-gate.d.ts.map +1 -1
- package/dist/payment-gate.js +275 -11
- package/dist/payment-gate.js.map +1 -1
- package/dist/products.d.ts +40 -16
- package/dist/products.d.ts.map +1 -1
- package/dist/products.js +1078 -56
- package/dist/products.js.map +1 -1
- package/dist/strict-response.d.ts +21 -0
- package/dist/strict-response.d.ts.map +1 -0
- package/dist/strict-response.js +68 -0
- package/dist/strict-response.js.map +1 -0
- package/dist/webhooks.d.ts +77 -2
- package/dist/webhooks.d.ts.map +1 -1
- package/dist/webhooks.js +20 -0
- package/dist/webhooks.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @genesis-tech/genesispay-seller
|
|
2
2
|
|
|
3
|
-
Framework-agnostic
|
|
3
|
+
Framework-agnostic payment and x402 SDK for GenesisPay sellers.
|
|
4
4
|
|
|
5
5
|
Wrap any Web-standard `(Request) => Response` handler and it becomes a paid
|
|
6
6
|
endpoint:
|
|
@@ -15,13 +15,46 @@ endpoint:
|
|
|
15
15
|
Works with Next.js route handlers, Hono, Bun.serve, and anything else that
|
|
16
16
|
speaks the Fetch API.
|
|
17
17
|
|
|
18
|
+
Synchronous facilitator success is also fail-closed. The built-in adapter
|
|
19
|
+
requires a full transaction hash, the exact requested network and integer minor
|
|
20
|
+
amount, the exact payer when the signed payload identifies one, and explicit
|
|
21
|
+
`authorizationVerified: true` plus `settlementVerified: true` extensions. An
|
|
22
|
+
HTTP 2xx response or `success: true` without that complete evidence never runs
|
|
23
|
+
the paid handler.
|
|
24
|
+
|
|
25
|
+
When GenesisPay accepts a facilitator settlement asynchronously (HTTP 202), the
|
|
26
|
+
SDK requires a version-1 acceptance for the exact requested plan and, for the
|
|
27
|
+
generic facilitator flow, its canonical plan status URL. Every later status
|
|
28
|
+
body must repeat that same version and plan before the SDK trusts it. The SDK
|
|
29
|
+
polls the seller-authenticated status endpoint internally. Your paid handler
|
|
30
|
+
still runs only after the seller transfer is receipt-verified and has a real
|
|
31
|
+
transaction hash. Queue acceptance and a hash-bearing but unconfirmed
|
|
32
|
+
`submitted` state never release the resource. The default poll deadline is 120
|
|
33
|
+
seconds; set `settlementPollTimeoutMs` on `genesisPaySettlement` when a resource
|
|
34
|
+
server needs a smaller bound. A terminal `failed` or `expired` poll returns HTTP
|
|
35
|
+
409 or 410 respectively, never a 2xx resource response and never a
|
|
36
|
+
`PAYMENT-RESPONSE` settlement receipt. Once any valid poll reports submitted
|
|
37
|
+
transaction hash H, later malformed, unavailable, terminal or contradictory
|
|
38
|
+
polls retain H as outcome-unknown evidence; the SDK never weakens it into a
|
|
39
|
+
hashless retry path or replaces it with a conflicting hash. A valid payer-
|
|
40
|
+
broadcast hash is retained the same way when the initial 202 acceptance is
|
|
41
|
+
malformed or polling fails before the server reports a transaction hash. A
|
|
42
|
+
replayed acceptance is checked once even after its issuance window, so durable
|
|
43
|
+
submitted/settled evidence remains discoverable; otherwise queued polling stops
|
|
44
|
+
at that acceptance deadline. Once an exact-plan submitted hash is observed,
|
|
45
|
+
reconciliation continues for the configured local polling budget.
|
|
46
|
+
|
|
18
47
|
## Install
|
|
19
48
|
|
|
49
|
+
This README documents **1.5.0**, which requires protocol `^0.5.0`. Hosted
|
|
50
|
+
checkout `lineItems` and externally calculated order tax need a GenesisPay
|
|
51
|
+
deployment that accepts them.
|
|
52
|
+
|
|
20
53
|
```bash
|
|
21
|
-
npm install @genesis-tech/genesispay-seller
|
|
54
|
+
npm install @genesis-tech/genesispay-seller@1.5.0
|
|
22
55
|
```
|
|
23
56
|
|
|
24
|
-
## Quick start
|
|
57
|
+
## Quick start
|
|
25
58
|
|
|
26
59
|
The `GenesisPay` client configures from **just the seller API key**. The key prefix
|
|
27
60
|
(`gp_sk_test_…` / `gp_sk_live_…`) determines the mode and base URL, and the payout
|
|
@@ -35,18 +68,23 @@ const genesispay = new GenesisPay({ apiKey: process.env.GENESISPAY_SELLER_API_KE
|
|
|
35
68
|
// Human hosted checkout — the wallet is defaulted server-side from the key.
|
|
36
69
|
// `metadata` and `clientReferenceId` come back on retrieve() and on the webhook,
|
|
37
70
|
// so you never need your own table just to map a link back to a buyer:
|
|
38
|
-
const { publicId, payUrl } = await genesispay.checkout.create(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
71
|
+
const { publicId, payUrl } = await genesispay.checkout.create(
|
|
72
|
+
{
|
|
73
|
+
title: "50 credits",
|
|
74
|
+
amount: "5.00", // decimal string in `asset` — see "Amounts and assets" below
|
|
75
|
+
taxConfig: { version: 1, treatment: "taxable", rateBps: 2000, note: null },
|
|
76
|
+
clientReferenceId: order.id,
|
|
77
|
+
metadata: { buyerId: user.id, plan: "starter" },
|
|
78
|
+
returnUrl: "https://shop.example.com/thanks",
|
|
79
|
+
cancelUrl: "https://shop.example.com/cart",
|
|
80
|
+
},
|
|
81
|
+
{ idempotencyKey: `checkout-${order.id}` },
|
|
82
|
+
);
|
|
46
83
|
|
|
47
|
-
//
|
|
84
|
+
// Tolerant checkout views are for display and polling only. They are not
|
|
85
|
+
// fulfilment authority; use fulfillment.verify as shown below.
|
|
48
86
|
const session = await genesispay.checkout.retrieve(publicId);
|
|
49
|
-
|
|
87
|
+
renderPaymentStatus(session.paid);
|
|
50
88
|
// An unknown id throws GenesisPayNotFoundError, not GenesisPayConfigError — so a
|
|
51
89
|
// typo'd id is distinguishable from a broken key or an unreachable backend.
|
|
52
90
|
|
|
@@ -56,18 +94,125 @@ export const GET = genesispay.gate({ amountUsdc: "0.02" }).wrap(
|
|
|
56
94
|
);
|
|
57
95
|
```
|
|
58
96
|
|
|
59
|
-
###
|
|
97
|
+
### Strict fulfilment authority (1.0)
|
|
98
|
+
|
|
99
|
+
Fulfil only after `genesispay.fulfillment.verify()` returns `verified: true`.
|
|
100
|
+
The method retrieves seller-scoped evidence from GenesisPay, parses every known
|
|
101
|
+
authority field strictly, validates the Base asset deployment, and compares it
|
|
102
|
+
with your immutable expected contract. It does not accept an evidence object,
|
|
103
|
+
so a fabricated SDK-shaped value cannot authorize fulfilment.
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import {
|
|
107
|
+
GenesisPay,
|
|
108
|
+
GenesisPayContractMismatchError,
|
|
109
|
+
GenesisPayEvidenceError,
|
|
110
|
+
GenesisPayVersionError,
|
|
111
|
+
type ExpectedProductContract,
|
|
112
|
+
} from "@genesis-tech/genesispay-seller";
|
|
113
|
+
|
|
114
|
+
const genesispay = new GenesisPay({
|
|
115
|
+
apiKey: process.env.GENESISPAY_SELLER_API_KEY!,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const expected: ExpectedProductContract = {
|
|
119
|
+
kind: "product",
|
|
120
|
+
productId: "prod_starter",
|
|
121
|
+
sku: "starter-credits",
|
|
122
|
+
grossAmountMinor: 1_000_000n,
|
|
123
|
+
network: {
|
|
124
|
+
mode: "live",
|
|
125
|
+
network: "base",
|
|
126
|
+
chainId: 8453,
|
|
127
|
+
asset: "USDC",
|
|
128
|
+
tokenAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
129
|
+
minorUnitScale: 6,
|
|
130
|
+
},
|
|
131
|
+
settlementDestination: process.env.GENESISPAY_EXPECTED_PAY_TO! as `0x${string}`,
|
|
132
|
+
delivery: {
|
|
133
|
+
type: "url",
|
|
134
|
+
url: "https://shop.example.com/deliver/starter",
|
|
135
|
+
gate: null,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const result = await genesispay.fulfillment.verify({
|
|
141
|
+
// Redirect/webhook entitlement ID is only a locator, never proof itself.
|
|
142
|
+
locator: { entitlementId },
|
|
143
|
+
expected,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
if (result.verified) {
|
|
147
|
+
await fulfilOnce(result.payment.attemptId);
|
|
148
|
+
} else {
|
|
149
|
+
// not_found, not_confirmed, simulated, or entitlement_invalid are normal
|
|
150
|
+
// negative outcomes. None authorizes fulfilment.
|
|
151
|
+
logPaymentPending(result.reason, result.requestId);
|
|
152
|
+
}
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (
|
|
155
|
+
error instanceof GenesisPayContractMismatchError ||
|
|
156
|
+
error instanceof GenesisPayEvidenceError ||
|
|
157
|
+
error instanceof GenesisPayVersionError
|
|
158
|
+
) {
|
|
159
|
+
alertPaymentAuthorityFailure({
|
|
160
|
+
code: error.code,
|
|
161
|
+
requestId: error.requestId,
|
|
162
|
+
apiVersion: error.apiVersion,
|
|
163
|
+
mismatches:
|
|
164
|
+
error instanceof GenesisPayContractMismatchError ? error.mismatches : undefined,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The strict API is explicitly versioned with
|
|
172
|
+
`GENESISPAY-Version: 2026-08-26`; the SDK sends that header and requires the
|
|
173
|
+
same version plus `GENESISPAY-Request-Id` in the response headers and body.
|
|
174
|
+
Amounts cross the wire as bounded canonical decimal strings and become
|
|
175
|
+
`bigint` only after validation. `simulated: false` is required literally and
|
|
176
|
+
is accepted only with the supported authority provenance version.
|
|
177
|
+
|
|
178
|
+
`2026-08-26` is a stable protocol identifier, not the SDK release date. Updating
|
|
179
|
+
the SDK package version does not change stored payment provenance.
|
|
180
|
+
|
|
181
|
+
For account-bound or per-order delivery, store the created `checkout.linkId`
|
|
182
|
+
alongside your order and buyer. After verification, require that the verified
|
|
183
|
+
link belongs to that order and authenticated buyer, and atomically claim the
|
|
184
|
+
attempt before granting credits or goods. A matching product contract alone
|
|
185
|
+
does not bind a payment to the currently signed-in customer.
|
|
186
|
+
|
|
187
|
+
Fee quotes and fee deductions are different: a `record_only` quote may equal
|
|
188
|
+
the gross amount, while the seller still received full gross. A quote above
|
|
189
|
+
gross is invalid, and a `payer_authorized` fee must leave a positive seller
|
|
190
|
+
amount. Never deduct a record-only quote from the verified seller amount.
|
|
191
|
+
|
|
192
|
+
For URL delivery, use the entitlement locator as above: it also checks current
|
|
193
|
+
expiry and revocation. An attempt or link locator proves the immutable payment
|
|
194
|
+
even after an entitlement is revoked or expires, for reconciliation. If you use
|
|
195
|
+
one of those locators to release URL delivery, also require a non-null
|
|
196
|
+
`result.payment.entitlement` with `valid === true` before fulfilling.
|
|
197
|
+
|
|
198
|
+
Use a key whose mode matches the payment chain: `test` for Base Sepolia and
|
|
199
|
+
`live` for Base mainnet. Strict direct/product checkout link creation/replay and
|
|
200
|
+
product-gate
|
|
201
|
+
requests reject `409 seller_mode_mismatch` before exposing or settling a payment
|
|
202
|
+
the same key cannot verify. A `baseUrl` override does not change key mode.
|
|
203
|
+
|
|
204
|
+
### Return URL — parse the hint, then verify authority
|
|
60
205
|
|
|
61
206
|
When you pass `returnUrl` to `checkout.create`, the hosted checkout shows a
|
|
62
207
|
"Return to …" button and appends
|
|
63
208
|
`?genesispay_link_id=<publicId>&genesispay_status=paid` only when the payer
|
|
64
209
|
selects it — there is no timed auto-redirect. Those parameters are a **UI hint
|
|
65
210
|
only**: they are unsigned, and a payer can navigate to that URL directly without
|
|
66
|
-
paying. **Never fulfil on the query string**; use webhooks as
|
|
67
|
-
|
|
211
|
+
paying. **Never fulfil on the query string**; use authenticated webhooks as a
|
|
212
|
+
trigger and strict verification as authority.
|
|
68
213
|
|
|
69
|
-
Use `parseCheckoutReturnHint` to
|
|
70
|
-
|
|
214
|
+
Use `parseCheckoutReturnHint` only to locate the payment, then call the strict
|
|
215
|
+
seller-authenticated verifier with the expected immutable contract:
|
|
71
216
|
|
|
72
217
|
```ts
|
|
73
218
|
import { parseCheckoutReturnHint } from "@genesis-tech/genesispay-seller";
|
|
@@ -76,15 +221,11 @@ export async function GET(request: Request) {
|
|
|
76
221
|
const hint = parseCheckoutReturnHint(new URL(request.url));
|
|
77
222
|
if (!hint) return Response.json({ ok: true }); // no return params — nothing to do
|
|
78
223
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (session.linkType !== "single") return Response.json({ ok: true });
|
|
85
|
-
|
|
86
|
-
// Single-use link: fulfil once, keyed by the link itself.
|
|
87
|
-
if (session.paid) await fulfilOnce(hint.linkId);
|
|
224
|
+
const result = await genesispay.fulfillment.verify({
|
|
225
|
+
locator: { linkId: hint.linkId },
|
|
226
|
+
expected: expectedSingleUseLinkContract,
|
|
227
|
+
});
|
|
228
|
+
if (result.verified) await fulfilOnce(result.payment.attemptId);
|
|
88
229
|
|
|
89
230
|
return Response.json({ ok: true });
|
|
90
231
|
}
|
|
@@ -96,11 +237,69 @@ Anything else — a missing id, an unknown status like `refunded`, a hand-built
|
|
|
96
237
|
— returns `null`. It performs **no verification**: treat it as a prompt to look
|
|
97
238
|
the link up with your key, never as a receipt.
|
|
98
239
|
|
|
99
|
-
This
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
240
|
+
This locator is suitable only for a single-use link. A reusable link can have
|
|
241
|
+
many confirmed attempts, so a link-only locator is ambiguous; use the attempt ID
|
|
242
|
+
from the webhook or recovery response. The browser return remains an untrusted
|
|
243
|
+
UX/recovery hint.
|
|
244
|
+
|
|
245
|
+
### Payment requests and products
|
|
246
|
+
|
|
247
|
+
`checkout.create()` creates a single-use payment request. Omit `linkType` or
|
|
248
|
+
pass `"single"`; `"reusable"` is rejected locally and by the API (HTTP 422).
|
|
249
|
+
For repeat sales, create a Product and mint its canonical payment link with
|
|
250
|
+
`products.createPaymentLink()`. Each hosted Buy starts a separate checkout
|
|
251
|
+
session. Completed checkouts do not offer a pay-again action. Legacy standalone
|
|
252
|
+
reusable links become single-use requests; previously paid ones are closed,
|
|
253
|
+
while their payments and documents remain available.
|
|
254
|
+
|
|
255
|
+
For a cart or external order, pass an immutable payer-facing `lineItems`
|
|
256
|
+
snapshot. Each `amount` is the total for that line—not a floating-point unit
|
|
257
|
+
price—and every line must add up exactly to the checkout `amount`:
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
await genesispay.checkout.create({
|
|
261
|
+
title: "Order #1042",
|
|
262
|
+
amount: "19.99",
|
|
263
|
+
lineItems: [
|
|
264
|
+
{
|
|
265
|
+
name: "Travel packing cube set",
|
|
266
|
+
description: "Color: Black",
|
|
267
|
+
quantity: 1,
|
|
268
|
+
amount: "15.00",
|
|
269
|
+
sku: "CUBE-BLK",
|
|
270
|
+
imageUrl: "https://shop.example/cube.jpg",
|
|
271
|
+
},
|
|
272
|
+
{ name: "Shipping", quantity: 1, amount: "4.99" },
|
|
273
|
+
],
|
|
274
|
+
}, { idempotencyKey: "order-1042-create" });
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
WooCommerce may freeze its exact calculated order tax instead of inventing one
|
|
278
|
+
aggregate rate. This is accepted only on authenticated idempotent single-use
|
|
279
|
+
creation; every line needs a `kind`, non-tax lines equal `subtotalMinor`, tax
|
|
280
|
+
lines equal `taxMinor`, and the gross equals `totalMinor`:
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
await genesispay.checkout.create({
|
|
284
|
+
title: "WooCommerce order #1042",
|
|
285
|
+
amount: "71.96",
|
|
286
|
+
taxConfig: {
|
|
287
|
+
version: 2,
|
|
288
|
+
calculation: "external",
|
|
289
|
+
source: "woocommerce",
|
|
290
|
+
subtotalMinor: "59980000",
|
|
291
|
+
taxMinor: "11980000",
|
|
292
|
+
totalMinor: "71960000",
|
|
293
|
+
},
|
|
294
|
+
lineItems: [
|
|
295
|
+
{ name: "Products and shipping", quantity: 1, kind: "item", amount: "59.98" },
|
|
296
|
+
{ name: "Tax", quantity: 1, kind: "tax", amount: "11.98" },
|
|
297
|
+
],
|
|
298
|
+
}, { idempotencyKey: "wc-order-1042" });
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
The external assertion is immutable. GenesisPay verifies its integer identity
|
|
302
|
+
and shows the exact tax but does not determine jurisdiction, rates or filings.
|
|
104
303
|
|
|
105
304
|
### Amounts and settlement
|
|
106
305
|
|
|
@@ -109,7 +308,10 @@ amount is therefore a decimal dollar string — never a number:
|
|
|
109
308
|
|
|
110
309
|
```ts
|
|
111
310
|
// 5 USDC / 5 dollars:
|
|
112
|
-
await genesispay.checkout.create(
|
|
311
|
+
await genesispay.checkout.create(
|
|
312
|
+
{ title: "Credits", amount: "5.00" },
|
|
313
|
+
{ idempotencyKey: "order-123-create" },
|
|
314
|
+
);
|
|
113
315
|
```
|
|
114
316
|
|
|
115
317
|
Buyers can use EUR or USD in the Privy/MoonPay flow; MoonPay converts the local
|
|
@@ -145,7 +347,13 @@ export async function POST(request: Request) {
|
|
|
145
347
|
request.headers.get("GENESISPAY-SIGNATURE") ?? "",
|
|
146
348
|
process.env.GENESISPAY_WEBHOOK_SECRET!,
|
|
147
349
|
);
|
|
148
|
-
if (event.type === "payment.confirmed")
|
|
350
|
+
if (event.type === "payment.confirmed") {
|
|
351
|
+
const verified = await genesispay.fulfillment.verify({
|
|
352
|
+
locator: { attemptId: event.data.attempt.id },
|
|
353
|
+
expected: expectedContractFor(event.data.link.publicId),
|
|
354
|
+
});
|
|
355
|
+
if (verified.verified) await fulfilOnce(verified.payment.attemptId);
|
|
356
|
+
}
|
|
149
357
|
return new Response(null, { status: 204 });
|
|
150
358
|
} catch (error) {
|
|
151
359
|
if (error instanceof GenesisPaySignatureVerificationError) {
|
|
@@ -188,28 +396,20 @@ together, since the same hash resolves to nothing on the wrong chain. It is on
|
|
|
188
396
|
the *attempt*, not the link, because a pay-by-bank settlement mints on whatever
|
|
189
397
|
chain the provider uses, which need not be the link's.
|
|
190
398
|
|
|
191
|
-
A missing
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
- *Retries*: a delivery is attempted up to three times on a non-2xx response, and
|
|
207
|
-
`event.id` is stable across those attempts. Dedupe on `event.id` before fulfilling.
|
|
208
|
-
- *The event pair*: a **single-use** link emits both `payment.confirmed` and
|
|
209
|
-
`link.paid` for the same settlement. These are two distinct deliveries with two
|
|
210
|
-
**different** `event.id`s, so `event.id` will not collapse them — branch on
|
|
211
|
-
`event.type` and fulfil on one of them. Reusable links emit only
|
|
212
|
-
`payment.confirmed`.
|
|
399
|
+
A missing or conflicting chain is not fulfillment evidence. Recover the attempt
|
|
400
|
+
through authenticated `fulfillment.verify` against the expected network; do not
|
|
401
|
+
infer the environment, default the chain, or book from tolerant checkout display.
|
|
402
|
+
Strict evidence supports the explicit Base deployment tuple in your contract.
|
|
403
|
+
|
|
404
|
+
**One settlement can reach you through multiple triggers.**
|
|
405
|
+
|
|
406
|
+
- *Retries*: nine sends per cycle, with stable source `event.id` across endpoints,
|
|
407
|
+
retries and audited replay. Persist inbox work before acknowledging.
|
|
408
|
+
- *Event types*: strict payments emit `payment.fulfilled` alongside legacy
|
|
409
|
+
`payment.confirmed`, `link.paid` and product notifications as applicable. Distinct
|
|
410
|
+
event IDs do not collapse one payment across event types or browser/recovery
|
|
411
|
+
triggers. Always use the shared verified-attempt/intent credit transaction
|
|
412
|
+
described in the fulfillment guide below.
|
|
213
413
|
|
|
214
414
|
### Invoices
|
|
215
415
|
|
|
@@ -229,9 +429,10 @@ const draft = await genesispay.invoices.create({
|
|
|
229
429
|
customerId: customer.publicId,
|
|
230
430
|
asset: "EURC",
|
|
231
431
|
dueAt: "2026-08-31T23:59:59.999Z",
|
|
232
|
-
|
|
432
|
+
calculationVersion: 2,
|
|
233
433
|
lineItems: [
|
|
234
|
-
{ description: "Consulting", quantity: 2, unitAmount: "450.00"
|
|
434
|
+
{ description: "Consulting", quantity: 2, unitAmount: "450.00",
|
|
435
|
+
taxConfig: { version: 1, treatment: "taxable", rateBps: 2300, note: null } },
|
|
235
436
|
],
|
|
236
437
|
});
|
|
237
438
|
|
|
@@ -245,12 +446,89 @@ invoice.pdfUrl; // printable PDF
|
|
|
245
446
|
invoice.payment.payUrl; // canonical GenesisPay checkout
|
|
246
447
|
```
|
|
247
448
|
|
|
449
|
+
New invoices use inclusive per-line tax: `unitAmount` includes tax. Each line
|
|
450
|
+
requires `taxConfig`; invoice-wide `taxBps` is zero. Existing v1 drafts keep
|
|
451
|
+
their original exclusive calculation; send `calculationVersion: 1` when editing
|
|
452
|
+
one. To explicitly upgrade a draft, supply `calculationVersion: 2`, `taxBps: 0`
|
|
453
|
+
and a confirmed `taxConfig` for every line. Finalized history cannot be upgraded.
|
|
454
|
+
|
|
248
455
|
All invoice money fields ending in `Minor` are integer strings. `paid` is a
|
|
249
456
|
read-only projection of a confirmed, non-simulated GenesisPay payment; it cannot
|
|
250
457
|
be set through the SDK. Finalized invoices cannot be edited. Use
|
|
251
458
|
`invoices.void()` to cancel collection or `invoices.markUncollectible()` to write
|
|
252
459
|
off an unpaid invoice.
|
|
253
460
|
|
|
461
|
+
### Browse invoice summaries
|
|
462
|
+
|
|
463
|
+
`invoices.listSummaries()` is available since 1.3.0. An older installed release
|
|
464
|
+
lacks the method; it can call the authenticated HTTP endpoint directly:
|
|
465
|
+
|
|
466
|
+
```ts
|
|
467
|
+
const response = await fetch(`${baseUrl}/api/v1/invoices/summaries?limit=25`, {
|
|
468
|
+
headers: { Authorization: `Bearer ${sellerApiKey}` },
|
|
469
|
+
});
|
|
470
|
+
if (!response.ok) throw new Error(`Invoice list failed: ${response.status}`);
|
|
471
|
+
const summaries = await response.json();
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
Use `invoices.listSummaries()` for bounded pages. The default limit is 25, with
|
|
475
|
+
an integer maximum of 100. Pass the opaque `nextCursor` back as `after`; a null
|
|
476
|
+
cursor means the end. Ordering is newest first by creation time and ID, so
|
|
477
|
+
inserts above your current page do not duplicate older rows. This is a live
|
|
478
|
+
list: status can change between requests.
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
let page = await genesispay.invoices.listSummaries({ limit: 25 });
|
|
482
|
+
for (const invoice of page.invoices) {
|
|
483
|
+
console.log(invoice.publicId, invoice.customer.name, invoice.totalMinor);
|
|
484
|
+
}
|
|
485
|
+
if (page.nextCursor) {
|
|
486
|
+
page = await genesispay.invoices.listSummaries({ limit: 25, after: page.nextCursor });
|
|
487
|
+
}
|
|
488
|
+
if (page.invoices[0]) {
|
|
489
|
+
const fullInvoice = await genesispay.invoices.retrieve(page.invoices[0].publicId);
|
|
490
|
+
}
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
Each summary has `id`, `publicId`, nullable `invoiceNumber`, `status`, `asset`,
|
|
494
|
+
`chainId`, exact integer-string `totalMinor`, `customer: { name, companyName }`,
|
|
495
|
+
`dueAt`, `createdAt`, and nullable `paidAt`. Finalized customer names come from
|
|
496
|
+
the issued snapshot. A summary intentionally has no line items, delivery
|
|
497
|
+
history, seller profile or payment detail; retrieve an invoice to read those.
|
|
498
|
+
The existing `invoices.list()` still returns its newest 100 full invoices with
|
|
499
|
+
every nested line and delivery. It does not accept pagination options.
|
|
500
|
+
|
|
501
|
+
### Hosted checkout documents
|
|
502
|
+
|
|
503
|
+
Every new confirmed, non-simulated hosted human checkout creates one immutable
|
|
504
|
+
invoice identity and two PDF artifacts: invoice and payment receipt. New human
|
|
505
|
+
authorizations require complete, attested KYB-approved issuer details and an
|
|
506
|
+
explicit item tax assertion; missing configuration blocks checkout, not silently
|
|
507
|
+
zero tax. Historical v1 enhanced receipts remain readable.
|
|
508
|
+
GenesisPay does not determine the seller's tax rate or require an Avalara or
|
|
509
|
+
Stripe Tax account. This archive is separate
|
|
510
|
+
from seller-authored `invoices`: do not try to create, edit, or number these
|
|
511
|
+
documents through the SDK.
|
|
512
|
+
|
|
513
|
+
```ts
|
|
514
|
+
const documents = await genesispay.checkoutDocuments.list();
|
|
515
|
+
const oneDocument = await genesispay.checkoutDocuments.get("doc_…");
|
|
516
|
+
|
|
517
|
+
for (const document of documents) {
|
|
518
|
+
console.log(document.kind, document.invoiceNumber, document.totalMinor);
|
|
519
|
+
// document.snapshot is the buyer/seller/tax snapshot frozen before payment.
|
|
520
|
+
}
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
Amounts ending in `Minor` are integer strings. A `null` `invoiceNumber` means
|
|
524
|
+
the document is an enhanced receipt, not an invoice with a missing number.
|
|
525
|
+
`checkoutDocuments.list()` returns the seller's newest 100 documents.
|
|
526
|
+
The authenticated PDF download is
|
|
527
|
+
`GET /api/v1/checkout-documents/:publicId/pdf` (invoice) and
|
|
528
|
+
`GET /api/v1/checkout-documents/:publicId/pdf?kind=receipt` (payment receipt).
|
|
529
|
+
V2 snapshots include per-line inclusive totals, item treatment, and the reviewed
|
|
530
|
+
seller profile version. Neither document creates another payable invoice.
|
|
531
|
+
|
|
254
532
|
### Subscriptions
|
|
255
533
|
|
|
256
534
|
Plans are the reusable template you create once and hand to any number of
|
|
@@ -273,10 +551,38 @@ further signatures and emit `subscription.renewed` (or `subscription.past_due`).
|
|
|
273
551
|
`genesispay.mandates.*` exposes the per-payer authorizations underneath —
|
|
274
552
|
`create`, `retrieve`, `charge` (per-use metering) and `revoke`.
|
|
275
553
|
|
|
554
|
+
Every per-use charge requires a stable idempotency key. Reuse it after any
|
|
555
|
+
timeout or 503; never generate a second key for the same metered operation:
|
|
556
|
+
|
|
557
|
+
```ts
|
|
558
|
+
await genesispay.mandates.charge(mandateId, {
|
|
559
|
+
amount: "0.08",
|
|
560
|
+
idempotencyKey: usageRequestId,
|
|
561
|
+
resourceUrl: "https://api.example/forecast",
|
|
562
|
+
});
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
If the pull was submitted but its receipt is not definitive, the promise rejects
|
|
566
|
+
with `GenesisPayMandateChargePendingError`. Its `status`, `code` and `charge`
|
|
567
|
+
fields preserve the API's 503 locator (including `charge.txHash` when known), so
|
|
568
|
+
the caller can record the unknown outcome and retry only with the same key.
|
|
569
|
+
|
|
570
|
+
`createMandateGate` likewise requires the protected request to carry
|
|
571
|
+
`Idempotency-Key`. An outcome-unknown pull stays 503 with its charge locator and
|
|
572
|
+
the wrapped handler is not opened until the original charge is settled. The
|
|
573
|
+
gate treats HTTP success as transport only: the response must also name a
|
|
574
|
+
settled charge, the exact requested integer minor amount and a full transaction
|
|
575
|
+
hash. A missing, submitted, hashless or contradictory 2xx body fails closed as
|
|
576
|
+
503 and the same idempotency key remains authoritative.
|
|
577
|
+
|
|
276
578
|
There is deliberately **no `mandates.activate`**: activation requires the payer's
|
|
277
579
|
own signature over the permit, so it belongs in the payer's frontend, not in a
|
|
278
580
|
server holding your secret key.
|
|
279
581
|
|
|
582
|
+
`MandateStatus` also includes terminal `cancelled`: GenesisPay stopped an
|
|
583
|
+
unbroadcast permit because seller eligibility changed. That authority never
|
|
584
|
+
wakes after recovery; create a new proposal and obtain a fresh payer signature.
|
|
585
|
+
|
|
280
586
|
**Cancelling a subscription** — list the plan's subscribers, find the payer, revoke:
|
|
281
587
|
|
|
282
588
|
```ts
|
|
@@ -314,7 +620,8 @@ and you get the same link with `created: false`.
|
|
|
314
620
|
```ts
|
|
315
621
|
const product = await genesispay.products.create({
|
|
316
622
|
name: "Market data report",
|
|
317
|
-
price: "2.00",
|
|
623
|
+
price: "2.00", // inclusive customer total
|
|
624
|
+
taxConfig: { version: 1, treatment: "taxable", rateBps: 2000, note: null },
|
|
318
625
|
sku: "MDR-1",
|
|
319
626
|
// A redirect product creates a signed, expiring entitlement on every sale.
|
|
320
627
|
delivery: {
|
|
@@ -330,6 +637,63 @@ const { link, created } = await genesispay.products.createPaymentLink(
|
|
|
330
637
|
// Share link.payUrl — every sale of this product settles through it.
|
|
331
638
|
```
|
|
332
639
|
|
|
640
|
+
#### Item tax and existing catalogue entries
|
|
641
|
+
|
|
642
|
+
`taxConfig` is `{ version: 1, treatment, rateBps, note }`. `rateBps` is an integer
|
|
643
|
+
(2000 = 20%), from 1 to 10000 for `taxable`. `zero_rated`, `exempt`, and
|
|
644
|
+
`not_collected` require rate 0 and a nonempty seller-provided explanation in
|
|
645
|
+
`note`. There is no automatic reverse charge or country-based tax determination.
|
|
646
|
+
Omitted tax on legacy API product/link creation remains **unconfigured**, not 0%;
|
|
647
|
+
new human checkout cannot start until the item and verified seller are ready.
|
|
648
|
+
Agent/x402 behavior remains separate. `checkout.create` accepts the same
|
|
649
|
+
version-1 `taxConfig`; strict WooCommerce order creation may instead use the
|
|
650
|
+
version-2 exact external assertion shown above.
|
|
651
|
+
|
|
652
|
+
Publish a tax correction using the exact configuration you last read:
|
|
653
|
+
|
|
654
|
+
```ts
|
|
655
|
+
await genesispay.products.update(product.publicId, {
|
|
656
|
+
expectedTaxConfig: product.taxConfig, // null for an unconfigured product
|
|
657
|
+
taxConfig: { version: 1, treatment: "taxable", rateBps: 1000, note: null },
|
|
658
|
+
});
|
|
659
|
+
// Standalone links only; product-backed links are changed through products.update:
|
|
660
|
+
await genesispay.checkout.updateTax(link.publicId, {
|
|
661
|
+
expectedTaxConfig: link.taxConfig,
|
|
662
|
+
taxConfig: { version: 1, treatment: "taxable", rateBps: 1000, note: null },
|
|
663
|
+
});
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
The server atomically publishes product tax to its live link without repricing
|
|
667
|
+
it. A concurrent edit returns 409: re-read and review before retrying. Existing
|
|
668
|
+
payment snapshots and issued documents never change. Archived and manual-invoice
|
|
669
|
+
links reject generic tax updates.
|
|
670
|
+
|
|
671
|
+
For an immutable per-order checkout, assert the complete current product
|
|
672
|
+
contract and create an explicitly identified single-use link. Both calls are
|
|
673
|
+
versioned; creation requires seller-account-scoped idempotency, so API-key
|
|
674
|
+
rotation does not break a retry:
|
|
675
|
+
|
|
676
|
+
```ts
|
|
677
|
+
await genesispay.products.assertContract(expectedProductContract);
|
|
678
|
+
|
|
679
|
+
const checkout = await genesispay.products.createCheckout(
|
|
680
|
+
{
|
|
681
|
+
expected: expectedProductContract,
|
|
682
|
+
clientReferenceId: order.id,
|
|
683
|
+
metadata: { buyerId: order.buyerId },
|
|
684
|
+
returnUrl: "https://shop.example.com/thanks",
|
|
685
|
+
},
|
|
686
|
+
{ idempotencyKey: `product-checkout-${order.id}` },
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
checkout.linkId; // GenesisPay link identity — there is no ambiguous checkout.id
|
|
690
|
+
checkout.payUrl;
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
The checkout copies the product's tax configuration inside the same transaction
|
|
694
|
+
that freezes its price and delivery contract. Tax never changes the signed gross
|
|
695
|
+
amount or serves as fulfilment authority.
|
|
696
|
+
|
|
333
697
|
#### Never hardcode `link.payUrl`
|
|
334
698
|
|
|
335
699
|
The URL embeds the link's `inv_…` id, and that id is **per link, not per
|
|
@@ -357,22 +721,20 @@ A confirmed purchase fires the `product.purchased` webhook. Its payload
|
|
|
357
721
|
carries the buyer's entitlement — `entitlement.redemptionPath` is a signed,
|
|
358
722
|
expiring redirect (~30 days) to your fulfilment URL, with `gp_*` parameters
|
|
359
723
|
(`gp_entitlement`, `gp_attempt`, `gp_simulated`, …) you can verify server-side
|
|
360
|
-
via
|
|
361
|
-
|
|
362
|
-
|
|
724
|
+
via the tolerant entitlement view for migration and display. Those parameters,
|
|
725
|
+
the webhook object, and `entitlements.verify` are not fulfilment authority in
|
|
726
|
+
1.0. Test-mode purchases still deliver end to end with an explicit simulation
|
|
727
|
+
marker, but only the strict verifier can return an authority-bearing success.
|
|
363
728
|
|
|
364
|
-
Use the
|
|
729
|
+
Use the strict authority verifier rather than trusting `gp_*` query parameters
|
|
730
|
+
or the tolerant entitlement view:
|
|
365
731
|
|
|
366
732
|
```ts
|
|
367
|
-
const verified = await genesispay.
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
) {
|
|
373
|
-
throw new Error("invalid entitlement");
|
|
374
|
-
}
|
|
375
|
-
// Insert verified.entitlement.publicId under a unique constraint, then fulfil.
|
|
733
|
+
const verified = await genesispay.fulfillment.verify({
|
|
734
|
+
locator: { entitlementId },
|
|
735
|
+
expected: expectedProductContract,
|
|
736
|
+
});
|
|
737
|
+
if (verified.verified) await fulfilOnce(verified.payment.attemptId);
|
|
376
738
|
```
|
|
377
739
|
|
|
378
740
|
### Product-backed API gate
|
|
@@ -400,13 +762,26 @@ Protect the route with that product. Validate request shape before calling
|
|
|
400
762
|
`protect`, and make handler effects idempotent by `purchase.payment.attemptId`:
|
|
401
763
|
|
|
402
764
|
```ts
|
|
765
|
+
import { createGateRequestFingerprint } from "@genesis-tech/genesispay-seller";
|
|
766
|
+
|
|
403
767
|
const forecastGate = genesispay.products.gate("prod_...");
|
|
404
768
|
|
|
405
769
|
export async function POST(request: Request) {
|
|
406
770
|
const rawBody = await request.clone().text();
|
|
407
771
|
validateForecastJson(rawBody); // invalid requests never create a payment attempt
|
|
408
772
|
|
|
409
|
-
|
|
773
|
+
const expectedForRequest = {
|
|
774
|
+
...expectedForecastContract,
|
|
775
|
+
delivery: {
|
|
776
|
+
...expectedForecastContract.delivery,
|
|
777
|
+
gate: {
|
|
778
|
+
...expectedForecastContract.delivery.gate,
|
|
779
|
+
fingerprint: await createGateRequestFingerprint(request),
|
|
780
|
+
},
|
|
781
|
+
},
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
return forecastGate.protect(request, expectedForRequest, async (_request, purchase) => {
|
|
410
785
|
const cached = await readForecast(purchase.payment.attemptId);
|
|
411
786
|
if (cached) return Response.json(cached);
|
|
412
787
|
|
|
@@ -417,19 +792,95 @@ export async function POST(request: Request) {
|
|
|
417
792
|
}
|
|
418
793
|
```
|
|
419
794
|
|
|
420
|
-
|
|
795
|
+
`protect` validates the method, canonical resource URL, and request fingerprint
|
|
796
|
+
against the expected immutable contract before negotiation. Its challenge sends
|
|
797
|
+
that complete contract to GenesisPay, which compares it with the exact frozen
|
|
798
|
+
payable link before creating an attempt or returning a `402 Payment Required`;
|
|
799
|
+
the SDK never reconstructs authority from mutable catalogue presentation.
|
|
421
800
|
GenesisPay creates a pending attempt before that response and advertises a
|
|
422
801
|
reserved `gp_attempt` value in the x402 resource URL. On the signed retry, the
|
|
423
802
|
SDK sends only the request fingerprint to GenesisPay; it never sends forecast
|
|
424
|
-
inputs.
|
|
425
|
-
|
|
426
|
-
|
|
803
|
+
inputs. After settlement it retrieves strict evidence by attempt ID and invokes
|
|
804
|
+
the handler only after every authority field matches. If evidence is unavailable
|
|
805
|
+
it returns a recoverable `503` carrying `GENESISPAY-Payment-Attempt-Id` and
|
|
806
|
+
preserves a matching `PAYMENT-RESPONSE`. A transient response is `retryable: true` and
|
|
807
|
+
carries `Retry-After: 2`; a permanent inconsistency is `retryable: false` and
|
|
808
|
+
deliberately carries no retry instruction. The gate transport also negotiates
|
|
809
|
+
`GENESISPAY-Version: 2026-08-26`, while unversioned 0.x gate traffic keeps its
|
|
810
|
+
legacy wire contract. If the settlement connection drops after commit, the SDK
|
|
811
|
+
recovers the untrusted attempt locator from the signed x402 payload and returns
|
|
812
|
+
the same retryable 503; only `fulfillment.verify` can turn that locator into
|
|
813
|
+
authority. Handlers may execute more than once and must keep their own result
|
|
814
|
+
cache.
|
|
815
|
+
|
|
816
|
+
An interrupted verification response stream is transient; a completed malformed
|
|
817
|
+
evidence response is permanent. A settlement naming a different attempt drops
|
|
818
|
+
that unrelated receipt and reports `settlement_outcome_unknown` with the signed
|
|
819
|
+
attempt locator. Missing strict settlement metadata or a negative
|
|
820
|
+
`not_found`/`not_confirmed` verification likewise cannot claim confirmation.
|
|
821
|
+
Only independently verified evidence allows the handler to run.
|
|
822
|
+
|
|
823
|
+
You may retry the original signed request after its authorization expires if
|
|
824
|
+
that attempt was already confirmed. GenesisPay verifies the persisted attempt,
|
|
825
|
+
signature and original on-chain payment before replaying success; it does not
|
|
826
|
+
broadcast or collect a fee again. An unpaid expired authorization remains
|
|
827
|
+
rejected. Keep the same attempt locator and signature when recovering a payment.
|
|
828
|
+
Failed/expired attempts are terminal even if their signature is still valid or
|
|
829
|
+
the original link has been archived (`409 attempt_failed` / `attempt_expired`).
|
|
427
830
|
|
|
428
831
|
`products.list({ includeArchived: true })`, `products.retrieve`,
|
|
429
832
|
`products.archive` complete the namespace. Archiving stops **new** link mints;
|
|
430
833
|
the existing canonical link stays payable. The price is copied onto the link
|
|
431
834
|
at mint — a later catalogue edit never changes what a buyer already sees.
|
|
432
835
|
|
|
836
|
+
#### Prepare requests for body-priced resources
|
|
837
|
+
|
|
838
|
+
Before an agent signs, it asks your resource to freeze the exact settlement
|
|
839
|
+
plan: a request carrying `GENESISPAY-Settlement-Prepare: 1`. A client that
|
|
840
|
+
puts its plan parameters in a second header,
|
|
841
|
+
`GENESISPAY-Settlement-Prepare-Params` (base64 JSON
|
|
842
|
+
`{ payer, idempotencyKey, feeMode, authority? }`, decoded by
|
|
843
|
+
`decodeSettlementPrepareParamsHeader` from `@genesis-tech/genesispay-protocol`),
|
|
844
|
+
sends that preparation as a **repeat of the purchase request** — same method,
|
|
845
|
+
URL (plus the reserved `gp_attempt` query), `content-type` and body.
|
|
846
|
+
|
|
847
|
+
**Rollout:** the GenesisPay agent engine does not send the params header yet;
|
|
848
|
+
that is a server follow-up. Until it ships, agents send the legacy form
|
|
849
|
+
described below, so a body-validated route must still accept the legacy
|
|
850
|
+
preparation body — or hand any request carrying
|
|
851
|
+
`GENESISPAY-Settlement-Prepare: 1` to `protect` before its own validation.
|
|
852
|
+
|
|
853
|
+
For a client that sends the header, your route needs no special case. Parse and
|
|
854
|
+
validate the body as for any purchase — read it from `request.clone()` (or pass
|
|
855
|
+
a re-created `Request`) so the gate can still read the body and recompute the
|
|
856
|
+
fingerprint; a consumed body answers `422 { code: "invalid_request" }` — select
|
|
857
|
+
the gate for it (for example the generic gate for a
|
|
858
|
+
request tier, or the product whose registered resource serves that tier) and
|
|
859
|
+
call `protect` or the wrapped handler. A body-priced API needs one gate
|
|
860
|
+
resource per price: a product gate is unique per method and resource URL, so
|
|
861
|
+
each tier is its own registered resource (or its own generic gate). The gate
|
|
862
|
+
recognises the preparation by its header, never runs your handler for it, and
|
|
863
|
+
answers with the plan:
|
|
864
|
+
|
|
865
|
+
- **Product gates** recompute the method, canonical resource URL and request
|
|
866
|
+
fingerprint and require them to equal your expected gate intent, exactly as
|
|
867
|
+
for the challenge and the signed retry. A preparation for a different request
|
|
868
|
+
answers `422 { code: "contract_mismatch", mismatches }` and nothing reaches
|
|
869
|
+
GenesisPay. The forwarded `prepare` action is unchanged.
|
|
870
|
+
- **The generic gate** (`createPaymentGate`) prices by configuration and binds
|
|
871
|
+
no request fingerprint; it reads the parameters from the header and treats
|
|
872
|
+
the body as opaque purchase content.
|
|
873
|
+
- A present but malformed params header answers
|
|
874
|
+
`422 { code: "invalid_request" }`; the gate never falls back to the body.
|
|
875
|
+
|
|
876
|
+
Without the params header, both gates keep the legacy form, in which the body
|
|
877
|
+
carries the plan parameters. It stays supported for GET resources, hosted
|
|
878
|
+
payment links and the current agent engine — but a route that rejects any body
|
|
879
|
+
other than its own purchase schema will refuse it, so such a route must
|
|
880
|
+
recognise the prepare header before its own validation until clients send the
|
|
881
|
+
header form. The decision is
|
|
882
|
+
recorded in ADR-0083 of the GenesisPay repository.
|
|
883
|
+
|
|
433
884
|
### Testing the paid path
|
|
434
885
|
|
|
435
886
|
```ts
|
|
@@ -439,8 +890,9 @@ const session = await genesispay.checkout.simulatePayment(publicId);
|
|
|
439
890
|
|
|
440
891
|
Test keys only — a `gp_sk_live_…` key gets a 403, and on a mainnet deployment the
|
|
441
892
|
endpoint does not exist at all. The resulting attempt has `txHash: null` (no
|
|
442
|
-
transaction happened) and `simulated: true
|
|
443
|
-
|
|
893
|
+
transaction happened) and `simulated: true` in tolerant display responses. Never
|
|
894
|
+
use that response to fulfil: strict `fulfillment.verify` returns the normal
|
|
895
|
+
negative reason `simulated`, while a pending real attempt can also have no hash.
|
|
444
896
|
|
|
445
897
|
### Fulfilment guide
|
|
446
898
|
|
|
@@ -452,15 +904,77 @@ handler run never double-delivers.
|
|
|
452
904
|
|
|
453
905
|
| Product | Fulfil on | Deduplicate by |
|
|
454
906
|
|---|---|---|
|
|
455
|
-
| Standalone single-use checkout |
|
|
456
|
-
|
|
|
457
|
-
| Redirect product |
|
|
458
|
-
| Product-backed gate |
|
|
907
|
+
| Standalone single-use checkout | strict `fulfillment.verify` by link or attempt | confirmed `payment.attemptId` |
|
|
908
|
+
| Product without digital delivery | strict `fulfillment.verify` by attempt | `payment.attemptId` — never link-level `paid` |
|
|
909
|
+
| Redirect product | strict `fulfillment.verify` by entitlement or attempt | `payment.attemptId` |
|
|
910
|
+
| Product-backed gate | strict evidence returned after confirmed settlement | `payment.attemptId` |
|
|
911
|
+
|
|
912
|
+
`payment.fulfilled` is the canonical notification. Its `data` is the strict
|
|
913
|
+
FulfillmentEvidence wire shape plus explicit nullable `clientReferenceId`; its
|
|
914
|
+
envelope has `apiVersion: "2026-08-26"` and `livemode`. Simulation does not emit
|
|
915
|
+
it. `constructEvent` verifies raw bytes before parsing and validates known evidence
|
|
916
|
+
fields, preserving integer amount strings. It does **not** return VerifiedPayment:
|
|
917
|
+
always call authenticated `fulfillment.verify({ locator, expected })` afterward.
|
|
918
|
+
The name does not claim that your application has delivered anything.
|
|
919
|
+
|
|
920
|
+
`livemode` is not present on every event type. Do not discard an event because
|
|
921
|
+
`!event.livemode` is true: an absent field also passes that check. For
|
|
922
|
+
`payment.fulfilled`, `livemode: false` means Base Sepolia, and
|
|
923
|
+
`data.simulation.simulated` is explicitly false. Network and simulation are
|
|
924
|
+
separate facts; never substitute `livemode` for the event's simulation field.
|
|
925
|
+
Use authenticated `fulfillment.verify` before granting credits or fulfilling
|
|
926
|
+
an order.
|
|
927
|
+
|
|
928
|
+
Persist a purchase intent (account, credits, expected contract) before checkout.
|
|
929
|
+
Use its ID as `clientReferenceId` and the checkout idempotency key. Store the
|
|
930
|
+
returned link ID and require `verified.payment.linkId` to match it before credit.
|
|
931
|
+
If the webhook precedes that response, keep it pending and recover the same
|
|
932
|
+
checkout; never assign a payment to an account from webhook metadata alone.
|
|
933
|
+
|
|
934
|
+
New event IDs are stable across endpoints, retries and explicit replay. Dedupe
|
|
935
|
+
inbox processing by event ID, but **atomically claim verified attempt ID and
|
|
936
|
+
purchase intent with the credit balance/ledger update** across every trigger.
|
|
937
|
+
Different event types, browser return and reconciliation must converge there.
|
|
938
|
+
Keep a conflicting binding for investigation; never grant a second credit.
|
|
939
|
+
Persist the inbox before acknowledging 2xx and let a worker retry verification.
|
|
940
|
+
|
|
941
|
+
There are nine sends per delivery cycle: initial plus 30s, 2m, 5m, 15m, 1h, 6h,
|
|
942
|
+
12h, 24h delays (±10% jitter), maximum72h. Minute scheduling rounds due work up to
|
|
943
|
+
the next tick. Only explicit audited operator replay opens another cycle, with
|
|
944
|
+
identical body and event ID. Acknowledgement loss permits duplicates. Outbox
|
|
945
|
+
producers/worker must be deployed and measured before relying on these targets.
|
|
946
|
+
|
|
947
|
+
### Recover missing notifications
|
|
459
948
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
949
|
+
```ts
|
|
950
|
+
let cursor: string | undefined;
|
|
951
|
+
do {
|
|
952
|
+
const page = await genesispay.fulfillment.listAttempts({
|
|
953
|
+
clientReferenceId: intent.id,
|
|
954
|
+
confirmedAfter: intent.createdAt,
|
|
955
|
+
limit: 50,
|
|
956
|
+
cursor,
|
|
957
|
+
});
|
|
958
|
+
for (const candidate of page.data) {
|
|
959
|
+
const verified = await genesispay.fulfillment.verify({
|
|
960
|
+
locator: { attemptId: candidate.attemptId }, expected: intent.expected,
|
|
961
|
+
});
|
|
962
|
+
if (verified.verified && verified.payment.linkId === intent.linkId) {
|
|
963
|
+
// Your shared transaction claims attempt + intent and writes credits.
|
|
964
|
+
await creditVerifiedPurchaseOnce(intent, verified.payment);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
cursor = page.nextCursor ?? undefined;
|
|
968
|
+
} while (cursor);
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
Listing requires the strict API version and seller key and enforces seller/mode
|
|
972
|
+
scope. Optional exact reference (max200), inclusive ISO confirmedAfter/Before,
|
|
973
|
+
limit1..100(default50) and opaque cursor are supported. Keep filters unchanged
|
|
974
|
+
between pages. A cursor fixes the upper bound and preserves database microseconds.
|
|
975
|
+
Repeat scans for late commits; include abandoned, expired and cancelled browser
|
|
976
|
+
flows. `authorityVersion: null` is a historical diagnostic, never permission to
|
|
977
|
+
credit. No listing row is authority, even when it names a confirmed attempt.
|
|
464
978
|
|
|
465
979
|
### Correlation, and the `cs` query parameter
|
|
466
980
|
|
|
@@ -492,18 +1006,27 @@ The `returnUrl`/`cancelUrl` limits and the amount-conflict check are validated
|
|
|
492
1006
|
remaining limits (`metadata`, `clientReferenceId`) are enforced server-side and
|
|
493
1007
|
fail the `checkout.create` call with a 422.
|
|
494
1008
|
|
|
495
|
-
### Note on `paid` for
|
|
1009
|
+
### Note on `paid` for product links
|
|
496
1010
|
|
|
497
1011
|
`session.paid` is derived from `confirmedPaymentCount > 0`. For a `reusable` link
|
|
498
1012
|
that counter only ever grows, so `paid` stays `true` from the first payment
|
|
499
1013
|
onward — it answers "has this link ever been paid", not "has *this* buyer paid".
|
|
500
|
-
For per-
|
|
501
|
-
`confirmedPaymentCount` as
|
|
1014
|
+
For per-purchase fulfilment on a product link, use a webhook as a trigger and then
|
|
1015
|
+
strictly verify its attempt ID. Never use `confirmedPaymentCount` as authority.
|
|
502
1016
|
|
|
503
1017
|
Options: `baseUrl` (override the mode default — https, http only for localhost;
|
|
504
1018
|
both modes default to the GenesisPay facilitator, so it is optional), `configTtlMs`
|
|
505
1019
|
(seller-config cache TTL, default 5 min), `expectedPayTo` (recommended for `live`
|
|
506
1020
|
keys — a local pin that fail-closes if the resolved wallet ever differs), `fetchFn`.
|
|
1021
|
+
The pin also applies to `checkout.retrieve`, `products.assertContract`,
|
|
1022
|
+
`products.createCheckout`, `products.gate(...).protect`, and `fulfillment.verify`.
|
|
1023
|
+
Checkout retrieval checks the raw destination before its display mapper runs;
|
|
1024
|
+
strict methods refuse a conflicting expected destination before a network request.
|
|
1025
|
+
Missing or conflicting destinations fail closed. An explicit expected contract
|
|
1026
|
+
cannot override the client pin, including on historical verification.
|
|
1027
|
+
After a wallet rotation, reconcile old payments with a separate client pinned
|
|
1028
|
+
to the original destination recorded in your immutable order contract. Keep the
|
|
1029
|
+
current client pinned to the new wallet; neither pin rewrites payment history.
|
|
507
1030
|
The client fails **closed**: an unreachable backend returns `503` and a seller with
|
|
508
1031
|
no wallet returns a `402` `payment_not_configured` — the paid handler never runs
|
|
509
1032
|
without a valid destination. It also refuses to advertise a wallet whose network
|
|
@@ -596,7 +1119,10 @@ The same wrapped handler drops straight into `Bun.serve({ fetch: gated })`.
|
|
|
596
1119
|
|
|
597
1120
|
`gate.wrap(handler, { verifySettlement })` requires a settlement hook. Use the
|
|
598
1121
|
built-in `genesisPaySettlement({ facilitatorBaseUrl, apiKey })`, which POSTs the
|
|
599
|
-
|
|
1122
|
+
plan-aware prepare request before an agent signs, then forwards the signed
|
|
1123
|
+
authorization with its immutable settlement-plan id. Clients that omit the
|
|
1124
|
+
prepare handshake are refused before broadcast with `settlement_plan_required`.
|
|
1125
|
+
The hook sends preparation and settlement to GenesisPay's facilitator. GenesisPay
|
|
600
1126
|
broadcasts the EIP-3009 authorization on-chain, verifies the USDC transfer,
|
|
601
1127
|
and returns the receipt. `facilitatorBaseUrl` is optional and defaults to the
|
|
602
1128
|
public development facilitator (`DEFAULT_FACILITATOR_BASE_URL`,
|
|
@@ -630,3 +1156,105 @@ const verifySettlement: VerifySettlement = async ({ payment, requirement }) => {
|
|
|
630
1156
|
your `verifySettlement` hook.
|
|
631
1157
|
- Get a seller API key (`gp_sk_...`) from your GenesisPay dashboard under
|
|
632
1158
|
Developers.
|
|
1159
|
+
|
|
1160
|
+
### Beta: new mandate authority answers 409
|
|
1161
|
+
|
|
1162
|
+
During the GenesisPay beta, a deployment may lock the creation of **new** mandate
|
|
1163
|
+
authority. While it is locked, `mandates.create`, the `contract_mandate_v1`
|
|
1164
|
+
helpers (`mandates.createContractSubscription`, `mandates.createContractPerUse`,
|
|
1165
|
+
`mandates.proposeContractUsage`) and `plans.create` reject with HTTP `409` and
|
|
1166
|
+
code `mandate_authority_locked_for_beta`. There is no `Retry-After` — waiting
|
|
1167
|
+
does not change the answer, so treat it as a capability that is off rather than a
|
|
1168
|
+
transient failure.
|
|
1169
|
+
|
|
1170
|
+
`mandates.submitContractUsage`, `mandates.getContractUsage`, revocation, every
|
|
1171
|
+
list/read, and the whole x402 payment-gate path are unaffected, as is any mandate
|
|
1172
|
+
a payer already approved. A retry of a request made before the lock still returns
|
|
1173
|
+
its original charge, mandate or payment identity — keep using the SAME
|
|
1174
|
+
idempotency key, exactly as you would without the lock. In particular a
|
|
1175
|
+
`mandates.charge` retry whose key already names a charge still comes back with
|
|
1176
|
+
that charge (settled, or `submitted` with its locator) rather than the 409, so a
|
|
1177
|
+
`409 mandate_authority_locked_for_beta` always means no charge exists for that
|
|
1178
|
+
key. Never retry with a fresh key to work around it.
|
|
1179
|
+
|
|
1180
|
+
### Experimental gated contract subscriptions
|
|
1181
|
+
|
|
1182
|
+
`mandates.createContractSubscription` explicitly requests the two-signature
|
|
1183
|
+
`contract_mandate_v1` protocol. It requires an ISO expiry and validates both
|
|
1184
|
+
returned signing payloads. This capability is off by default; the reviewed
|
|
1185
|
+
contract registry must permit the deployment. Mainnet also requires legal/audit
|
|
1186
|
+
and completed legacy-authority cutover evidence.
|
|
1187
|
+
|
|
1188
|
+
```ts
|
|
1189
|
+
const proposal = await genesispay.mandates.createContractSubscription({
|
|
1190
|
+
payerWallet, allowance: "108", capPerCharge: "9", amountPerPeriod: "9",
|
|
1191
|
+
periodDays: 30, validUntil: "2027-09-01T00:00:00Z",
|
|
1192
|
+
});
|
|
1193
|
+
// In the payer frontend, sign approval.termsTypedData, then approval.permitTypedData.
|
|
1194
|
+
// POST /api/v1/mandates/:id/activate:
|
|
1195
|
+
// { protocol: "contract_mandate_v1", termsSignature, permitSignature }
|
|
1196
|
+
```
|
|
1197
|
+
|
|
1198
|
+
HTTP202 is acceptance, not activation or payment confirmation. Retry the same
|
|
1199
|
+
signatures after an unknown HTTP outcome. A409 `mandate_proposal_stale` requires a
|
|
1200
|
+
new proposal and two new approvals. The legacy `mandates.create` method keeps its
|
|
1201
|
+
one-permit contract and cannot silently switch protocols.
|
|
1202
|
+
|
|
1203
|
+
`mandates.revoke(id)` requests local cancellation for contract mandates; inspect
|
|
1204
|
+
`revocationStatus`. The payer signs `revocationTypedData` from the returned API
|
|
1205
|
+
mandate and any relay can POST `{ protocol: "contract_mandate_v1", signature }`
|
|
1206
|
+
to `/api/v1/mandates/:id/revoke-signed`. Only `confirmed` means the permanent
|
|
1207
|
+
on-chain revocation exists. An independent relay may instead call the bound
|
|
1208
|
+
executor's `revokeWithSignature(payer, mandateId, signature)`; the payer can call
|
|
1209
|
+
`revoke(mandateId)` directly. Neither path requires an API session. Do not use
|
|
1210
|
+
`approve(0)` as proof that old signed permits are invalid.
|
|
1211
|
+
|
|
1212
|
+
Contract subscription renewals are admitted by the worker or cron and become paid
|
|
1213
|
+
only after canonical atomic receipt verification. First charge is eligible at
|
|
1214
|
+
activation; later charges wait the signed interval after successful settlement.
|
|
1215
|
+
Downtime does not create catch-up charges. A confirmed revert may receive a new
|
|
1216
|
+
attempt only after exact receipt and unused-authority verification.
|
|
1217
|
+
|
|
1218
|
+
Contract billing webhooks add `protocol: "contract_mandate_v1"`, `contractEvent`
|
|
1219
|
+
(immutable chain/block/log, logical identity, sequence and split amounts), and
|
|
1220
|
+
`mandateStateContext: { kind: "projection_snapshot", observedAt }`. `occurredAt`
|
|
1221
|
+
is the event's block time; `mandate` is the state when that event was projected,
|
|
1222
|
+
which can be later. Deliveries may arrive out of order: an old `mandate.active`
|
|
1223
|
+
event can carry an already revoked snapshot. Order event facts by block/log and
|
|
1224
|
+
retrieve current state before enabling access. Deduplicate the envelope `id`;
|
|
1225
|
+
replays preserve its original bytes, including the original snapshot.
|
|
1226
|
+
|
|
1227
|
+
### Experimental gated signed per-use mandates
|
|
1228
|
+
|
|
1229
|
+
`mandates.createContractPerUse({ payerWallet, allowance, capPerCharge, validUntil })`
|
|
1230
|
+
returns the same two bounded approvals as the contract subscription API. It has
|
|
1231
|
+
no recurring amount or interval. After the payer signs both approvals and the
|
|
1232
|
+
activation confirms, each usage request requires its own payer signature:
|
|
1233
|
+
|
|
1234
|
+
```ts
|
|
1235
|
+
const charge = await genesispay.mandates.proposeContractUsage({
|
|
1236
|
+
mandate: approvedMandate, // the saved createContractPerUse result
|
|
1237
|
+
amountMinor: "80000", // gross integer minor units, including any fee
|
|
1238
|
+
idempotencyKey: "forecast-request-001",
|
|
1239
|
+
resourceUrl: "https://example.com/forecast",
|
|
1240
|
+
});
|
|
1241
|
+
// The payer's wallet signs charge.chargeTypedData. The seller SDK never signs.
|
|
1242
|
+
const accepted = await genesispay.mandates.submitContractUsage(charge, payerSignature);
|
|
1243
|
+
const current = await genesispay.mandates.getContractUsage(accepted);
|
|
1244
|
+
// Fulfill only after current.status === "settled", verified against your order.
|
|
1245
|
+
```
|
|
1246
|
+
|
|
1247
|
+
The SDK independently builds the charge EIP-712 message from the saved mandate
|
|
1248
|
+
consent and exact requested gross. It checks chain, executor, mandate, fee,
|
|
1249
|
+
request metadata and signing fields. Submission and polling preserve the original
|
|
1250
|
+
charge identity and deadline. HTTP202 means pending; queue acceptance is not a
|
|
1251
|
+
confirmed payment. A retry uses the same idempotency key and saved proposal.
|
|
1252
|
+
Changing a request under that key returns a conflict, including after failure.
|
|
1253
|
+
|
|
1254
|
+
The server retains the proposal before a wallet prompt. If no signature arrives,
|
|
1255
|
+
it closes the source only after finalized proof that the charge is unused and
|
|
1256
|
+
its authority expired or was revoked. The old `mandates.charge` and metering gate
|
|
1257
|
+
are legacy-only and refuse contract mandates; they cannot silently replace the
|
|
1258
|
+
required signature. These contract APIs remain gated and unreleased on Mainnet.
|
|
1259
|
+
Agent policy/signing integration and per-use successors after a mined revert
|
|
1260
|
+
remain separate pending implementation work.
|