@genesis-tech/genesispay-seller 0.13.2 → 1.3.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 +196 -0
- package/README.md +670 -91
- package/dist/checkout-documents.d.ts +99 -0
- package/dist/checkout-documents.d.ts.map +1 -0
- package/dist/checkout-documents.js +155 -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 +42 -7
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +99 -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 +15 -0
- package/dist/item-tax.d.ts.map +1 -0
- package/dist/item-tax.js +19 -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 targets the coordinated **1.3.0 release candidate**. npm currently
|
|
50
|
+
serves 0.13.2. The candidate requires protocol 0.5.0 and the matching backend;
|
|
51
|
+
install it only after those versions are published and the deployment is ready.
|
|
52
|
+
|
|
20
53
|
```bash
|
|
21
|
-
npm install @genesis-tech/genesispay-seller
|
|
54
|
+
npm install @genesis-tech/genesispay-seller@1.3.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,21 @@ 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. No npm publication is
|
|
254
|
+
implied by this repository change.
|
|
104
255
|
|
|
105
256
|
### Amounts and settlement
|
|
106
257
|
|
|
@@ -109,7 +260,10 @@ amount is therefore a decimal dollar string — never a number:
|
|
|
109
260
|
|
|
110
261
|
```ts
|
|
111
262
|
// 5 USDC / 5 dollars:
|
|
112
|
-
await genesispay.checkout.create(
|
|
263
|
+
await genesispay.checkout.create(
|
|
264
|
+
{ title: "Credits", amount: "5.00" },
|
|
265
|
+
{ idempotencyKey: "order-123-create" },
|
|
266
|
+
);
|
|
113
267
|
```
|
|
114
268
|
|
|
115
269
|
Buyers can use EUR or USD in the Privy/MoonPay flow; MoonPay converts the local
|
|
@@ -145,7 +299,13 @@ export async function POST(request: Request) {
|
|
|
145
299
|
request.headers.get("GENESISPAY-SIGNATURE") ?? "",
|
|
146
300
|
process.env.GENESISPAY_WEBHOOK_SECRET!,
|
|
147
301
|
);
|
|
148
|
-
if (event.type === "payment.confirmed")
|
|
302
|
+
if (event.type === "payment.confirmed") {
|
|
303
|
+
const verified = await genesispay.fulfillment.verify({
|
|
304
|
+
locator: { attemptId: event.data.attempt.id },
|
|
305
|
+
expected: expectedContractFor(event.data.link.publicId),
|
|
306
|
+
});
|
|
307
|
+
if (verified.verified) await fulfilOnce(verified.payment.attemptId);
|
|
308
|
+
}
|
|
149
309
|
return new Response(null, { status: 204 });
|
|
150
310
|
} catch (error) {
|
|
151
311
|
if (error instanceof GenesisPaySignatureVerificationError) {
|
|
@@ -188,28 +348,20 @@ together, since the same hash resolves to nothing on the wrong chain. It is on
|
|
|
188
348
|
the *attempt*, not the link, because a pay-by-bank settlement mints on whatever
|
|
189
349
|
chain the provider uses, which need not be the link's.
|
|
190
350
|
|
|
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`.
|
|
351
|
+
A missing or conflicting chain is not fulfillment evidence. Recover the attempt
|
|
352
|
+
through authenticated `fulfillment.verify` against the expected network; do not
|
|
353
|
+
infer the environment, default the chain, or book from tolerant checkout display.
|
|
354
|
+
Strict evidence supports the explicit Base deployment tuple in your contract.
|
|
355
|
+
|
|
356
|
+
**One settlement can reach you through multiple triggers.**
|
|
357
|
+
|
|
358
|
+
- *Retries*: nine sends per cycle, with stable source `event.id` across endpoints,
|
|
359
|
+
retries and audited replay. Persist inbox work before acknowledging.
|
|
360
|
+
- *Event types*: strict payments emit `payment.fulfilled` alongside legacy
|
|
361
|
+
`payment.confirmed`, `link.paid` and product notifications as applicable. Distinct
|
|
362
|
+
event IDs do not collapse one payment across event types or browser/recovery
|
|
363
|
+
triggers. Always use the shared verified-attempt/intent credit transaction
|
|
364
|
+
described in the fulfillment guide below.
|
|
213
365
|
|
|
214
366
|
### Invoices
|
|
215
367
|
|
|
@@ -229,9 +381,10 @@ const draft = await genesispay.invoices.create({
|
|
|
229
381
|
customerId: customer.publicId,
|
|
230
382
|
asset: "EURC",
|
|
231
383
|
dueAt: "2026-08-31T23:59:59.999Z",
|
|
232
|
-
|
|
384
|
+
calculationVersion: 2,
|
|
233
385
|
lineItems: [
|
|
234
|
-
{ description: "Consulting", quantity: 2, unitAmount: "450.00"
|
|
386
|
+
{ description: "Consulting", quantity: 2, unitAmount: "450.00",
|
|
387
|
+
taxConfig: { version: 1, treatment: "taxable", rateBps: 2300, note: null } },
|
|
235
388
|
],
|
|
236
389
|
});
|
|
237
390
|
|
|
@@ -245,12 +398,90 @@ invoice.pdfUrl; // printable PDF
|
|
|
245
398
|
invoice.payment.payUrl; // canonical GenesisPay checkout
|
|
246
399
|
```
|
|
247
400
|
|
|
401
|
+
New invoices use inclusive per-line tax: `unitAmount` includes tax. Each line
|
|
402
|
+
requires `taxConfig`; invoice-wide `taxBps` is zero. Existing v1 drafts keep
|
|
403
|
+
their original exclusive calculation; send `calculationVersion: 1` when editing
|
|
404
|
+
one. To explicitly upgrade a draft, supply `calculationVersion: 2`, `taxBps: 0`
|
|
405
|
+
and a confirmed `taxConfig` for every line. Finalized history cannot be upgraded.
|
|
406
|
+
|
|
248
407
|
All invoice money fields ending in `Minor` are integer strings. `paid` is a
|
|
249
408
|
read-only projection of a confirmed, non-simulated GenesisPay payment; it cannot
|
|
250
409
|
be set through the SDK. Finalized invoices cannot be edited. Use
|
|
251
410
|
`invoices.void()` to cancel collection or `invoices.markUncollectible()` to write
|
|
252
411
|
off an unpaid invoice.
|
|
253
412
|
|
|
413
|
+
### Browse invoice summaries
|
|
414
|
+
|
|
415
|
+
`listSummaries` is prepared in this repository; this change does not publish an
|
|
416
|
+
SDK release. If your installed release lacks the method, use the authenticated
|
|
417
|
+
HTTP endpoint after deploying this backend change:
|
|
418
|
+
|
|
419
|
+
```ts
|
|
420
|
+
const response = await fetch(`${baseUrl}/api/v1/invoices/summaries?limit=25`, {
|
|
421
|
+
headers: { Authorization: `Bearer ${sellerApiKey}` },
|
|
422
|
+
});
|
|
423
|
+
if (!response.ok) throw new Error(`Invoice list failed: ${response.status}`);
|
|
424
|
+
const summaries = await response.json();
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
Use `invoices.listSummaries()` for bounded pages. The default limit is 25, with
|
|
428
|
+
an integer maximum of 100. Pass the opaque `nextCursor` back as `after`; a null
|
|
429
|
+
cursor means the end. Ordering is newest first by creation time and ID, so
|
|
430
|
+
inserts above your current page do not duplicate older rows. This is a live
|
|
431
|
+
list: status can change between requests.
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
let page = await genesispay.invoices.listSummaries({ limit: 25 });
|
|
435
|
+
for (const invoice of page.invoices) {
|
|
436
|
+
console.log(invoice.publicId, invoice.customer.name, invoice.totalMinor);
|
|
437
|
+
}
|
|
438
|
+
if (page.nextCursor) {
|
|
439
|
+
page = await genesispay.invoices.listSummaries({ limit: 25, after: page.nextCursor });
|
|
440
|
+
}
|
|
441
|
+
if (page.invoices[0]) {
|
|
442
|
+
const fullInvoice = await genesispay.invoices.retrieve(page.invoices[0].publicId);
|
|
443
|
+
}
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
Each summary has `id`, `publicId`, nullable `invoiceNumber`, `status`, `asset`,
|
|
447
|
+
`chainId`, exact integer-string `totalMinor`, `customer: { name, companyName }`,
|
|
448
|
+
`dueAt`, `createdAt`, and nullable `paidAt`. Finalized customer names come from
|
|
449
|
+
the issued snapshot. A summary intentionally has no line items, delivery
|
|
450
|
+
history, seller profile or payment detail; retrieve an invoice to read those.
|
|
451
|
+
The existing `invoices.list()` still returns its newest 100 full invoices with
|
|
452
|
+
every nested line and delivery. It does not accept pagination options.
|
|
453
|
+
|
|
454
|
+
### Hosted checkout documents
|
|
455
|
+
|
|
456
|
+
Every new confirmed, non-simulated hosted human checkout creates one immutable
|
|
457
|
+
invoice identity and two PDF artifacts: invoice and payment receipt. New human
|
|
458
|
+
authorizations require complete, attested KYB-approved issuer details and an
|
|
459
|
+
explicit item tax assertion; missing configuration blocks checkout, not silently
|
|
460
|
+
zero tax. Historical v1 enhanced receipts remain readable.
|
|
461
|
+
GenesisPay does not determine the seller's tax rate or require an Avalara or
|
|
462
|
+
Stripe Tax account. This archive is separate
|
|
463
|
+
from seller-authored `invoices`: do not try to create, edit, or number these
|
|
464
|
+
documents through the SDK.
|
|
465
|
+
|
|
466
|
+
```ts
|
|
467
|
+
const documents = await genesispay.checkoutDocuments.list();
|
|
468
|
+
const oneDocument = await genesispay.checkoutDocuments.get("doc_…");
|
|
469
|
+
|
|
470
|
+
for (const document of documents) {
|
|
471
|
+
console.log(document.kind, document.invoiceNumber, document.totalMinor);
|
|
472
|
+
// document.snapshot is the buyer/seller/tax snapshot frozen before payment.
|
|
473
|
+
}
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
Amounts ending in `Minor` are integer strings. A `null` `invoiceNumber` means
|
|
477
|
+
the document is an enhanced receipt, not an invoice with a missing number.
|
|
478
|
+
`checkoutDocuments.list()` returns the seller's newest 100 documents.
|
|
479
|
+
The authenticated PDF download is
|
|
480
|
+
`GET /api/v1/checkout-documents/:publicId/pdf` (invoice) and
|
|
481
|
+
`GET /api/v1/checkout-documents/:publicId/pdf?kind=receipt` (payment receipt).
|
|
482
|
+
V2 snapshots include per-line inclusive totals, item treatment, and the reviewed
|
|
483
|
+
seller profile version. Neither document creates another payable invoice.
|
|
484
|
+
|
|
254
485
|
### Subscriptions
|
|
255
486
|
|
|
256
487
|
Plans are the reusable template you create once and hand to any number of
|
|
@@ -273,10 +504,38 @@ further signatures and emit `subscription.renewed` (or `subscription.past_due`).
|
|
|
273
504
|
`genesispay.mandates.*` exposes the per-payer authorizations underneath —
|
|
274
505
|
`create`, `retrieve`, `charge` (per-use metering) and `revoke`.
|
|
275
506
|
|
|
507
|
+
Every per-use charge requires a stable idempotency key. Reuse it after any
|
|
508
|
+
timeout or 503; never generate a second key for the same metered operation:
|
|
509
|
+
|
|
510
|
+
```ts
|
|
511
|
+
await genesispay.mandates.charge(mandateId, {
|
|
512
|
+
amount: "0.08",
|
|
513
|
+
idempotencyKey: usageRequestId,
|
|
514
|
+
resourceUrl: "https://api.example/forecast",
|
|
515
|
+
});
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
If the pull was submitted but its receipt is not definitive, the promise rejects
|
|
519
|
+
with `GenesisPayMandateChargePendingError`. Its `status`, `code` and `charge`
|
|
520
|
+
fields preserve the API's 503 locator (including `charge.txHash` when known), so
|
|
521
|
+
the caller can record the unknown outcome and retry only with the same key.
|
|
522
|
+
|
|
523
|
+
`createMandateGate` likewise requires the protected request to carry
|
|
524
|
+
`Idempotency-Key`. An outcome-unknown pull stays 503 with its charge locator and
|
|
525
|
+
the wrapped handler is not opened until the original charge is settled. The
|
|
526
|
+
gate treats HTTP success as transport only: the response must also name a
|
|
527
|
+
settled charge, the exact requested integer minor amount and a full transaction
|
|
528
|
+
hash. A missing, submitted, hashless or contradictory 2xx body fails closed as
|
|
529
|
+
503 and the same idempotency key remains authoritative.
|
|
530
|
+
|
|
276
531
|
There is deliberately **no `mandates.activate`**: activation requires the payer's
|
|
277
532
|
own signature over the permit, so it belongs in the payer's frontend, not in a
|
|
278
533
|
server holding your secret key.
|
|
279
534
|
|
|
535
|
+
`MandateStatus` also includes terminal `cancelled`: GenesisPay stopped an
|
|
536
|
+
unbroadcast permit because seller eligibility changed. That authority never
|
|
537
|
+
wakes after recovery; create a new proposal and obtain a fresh payer signature.
|
|
538
|
+
|
|
280
539
|
**Cancelling a subscription** — list the plan's subscribers, find the payer, revoke:
|
|
281
540
|
|
|
282
541
|
```ts
|
|
@@ -314,7 +573,8 @@ and you get the same link with `created: false`.
|
|
|
314
573
|
```ts
|
|
315
574
|
const product = await genesispay.products.create({
|
|
316
575
|
name: "Market data report",
|
|
317
|
-
price: "2.00",
|
|
576
|
+
price: "2.00", // inclusive customer total
|
|
577
|
+
taxConfig: { version: 1, treatment: "taxable", rateBps: 2000, note: null },
|
|
318
578
|
sku: "MDR-1",
|
|
319
579
|
// A redirect product creates a signed, expiring entitlement on every sale.
|
|
320
580
|
delivery: {
|
|
@@ -330,6 +590,61 @@ const { link, created } = await genesispay.products.createPaymentLink(
|
|
|
330
590
|
// Share link.payUrl — every sale of this product settles through it.
|
|
331
591
|
```
|
|
332
592
|
|
|
593
|
+
#### Item tax and existing catalogue entries
|
|
594
|
+
|
|
595
|
+
`taxConfig` is `{ version: 1, treatment, rateBps, note }`. `rateBps` is an integer
|
|
596
|
+
(2000 = 20%), from 1 to 10000 for `taxable`. `zero_rated`, `exempt`, and
|
|
597
|
+
`not_collected` require rate 0 and a nonempty seller-provided explanation in
|
|
598
|
+
`note`. There is no automatic reverse charge or country-based tax determination.
|
|
599
|
+
Omitted tax on legacy API product/link creation remains **unconfigured**, not 0%;
|
|
600
|
+
new human checkout cannot start until the item and verified seller are ready.
|
|
601
|
+
Agent/x402 behavior remains separate. `checkout.create` accepts the same taxConfig.
|
|
602
|
+
|
|
603
|
+
Publish a tax correction using the exact configuration you last read:
|
|
604
|
+
|
|
605
|
+
```ts
|
|
606
|
+
await genesispay.products.update(product.publicId, {
|
|
607
|
+
expectedTaxConfig: product.taxConfig, // null for an unconfigured product
|
|
608
|
+
taxConfig: { version: 1, treatment: "taxable", rateBps: 1000, note: null },
|
|
609
|
+
});
|
|
610
|
+
// Standalone links only; product-backed links are changed through products.update:
|
|
611
|
+
await genesispay.checkout.updateTax(link.publicId, {
|
|
612
|
+
expectedTaxConfig: link.taxConfig,
|
|
613
|
+
taxConfig: { version: 1, treatment: "taxable", rateBps: 1000, note: null },
|
|
614
|
+
});
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
The server atomically publishes product tax to its live link without repricing
|
|
618
|
+
it. A concurrent edit returns 409: re-read and review before retrying. Existing
|
|
619
|
+
payment snapshots and issued documents never change. Archived and manual-invoice
|
|
620
|
+
links reject generic tax updates.
|
|
621
|
+
|
|
622
|
+
For an immutable per-order checkout, assert the complete current product
|
|
623
|
+
contract and create an explicitly identified single-use link. Both calls are
|
|
624
|
+
versioned; creation requires seller-account-scoped idempotency, so API-key
|
|
625
|
+
rotation does not break a retry:
|
|
626
|
+
|
|
627
|
+
```ts
|
|
628
|
+
await genesispay.products.assertContract(expectedProductContract);
|
|
629
|
+
|
|
630
|
+
const checkout = await genesispay.products.createCheckout(
|
|
631
|
+
{
|
|
632
|
+
expected: expectedProductContract,
|
|
633
|
+
clientReferenceId: order.id,
|
|
634
|
+
metadata: { buyerId: order.buyerId },
|
|
635
|
+
returnUrl: "https://shop.example.com/thanks",
|
|
636
|
+
},
|
|
637
|
+
{ idempotencyKey: `product-checkout-${order.id}` },
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
checkout.linkId; // GenesisPay link identity — there is no ambiguous checkout.id
|
|
641
|
+
checkout.payUrl;
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
The checkout copies the product's tax configuration inside the same transaction
|
|
645
|
+
that freezes its price and delivery contract. Tax never changes the signed gross
|
|
646
|
+
amount or serves as fulfilment authority.
|
|
647
|
+
|
|
333
648
|
#### Never hardcode `link.payUrl`
|
|
334
649
|
|
|
335
650
|
The URL embeds the link's `inv_…` id, and that id is **per link, not per
|
|
@@ -357,22 +672,20 @@ A confirmed purchase fires the `product.purchased` webhook. Its payload
|
|
|
357
672
|
carries the buyer's entitlement — `entitlement.redemptionPath` is a signed,
|
|
358
673
|
expiring redirect (~30 days) to your fulfilment URL, with `gp_*` parameters
|
|
359
674
|
(`gp_entitlement`, `gp_attempt`, `gp_simulated`, …) you can verify server-side
|
|
360
|
-
via
|
|
361
|
-
|
|
362
|
-
|
|
675
|
+
via the tolerant entitlement view for migration and display. Those parameters,
|
|
676
|
+
the webhook object, and `entitlements.verify` are not fulfilment authority in
|
|
677
|
+
1.0. Test-mode purchases still deliver end to end with an explicit simulation
|
|
678
|
+
marker, but only the strict verifier can return an authority-bearing success.
|
|
363
679
|
|
|
364
|
-
Use the
|
|
680
|
+
Use the strict authority verifier rather than trusting `gp_*` query parameters
|
|
681
|
+
or the tolerant entitlement view:
|
|
365
682
|
|
|
366
683
|
```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.
|
|
684
|
+
const verified = await genesispay.fulfillment.verify({
|
|
685
|
+
locator: { entitlementId },
|
|
686
|
+
expected: expectedProductContract,
|
|
687
|
+
});
|
|
688
|
+
if (verified.verified) await fulfilOnce(verified.payment.attemptId);
|
|
376
689
|
```
|
|
377
690
|
|
|
378
691
|
### Product-backed API gate
|
|
@@ -400,13 +713,26 @@ Protect the route with that product. Validate request shape before calling
|
|
|
400
713
|
`protect`, and make handler effects idempotent by `purchase.payment.attemptId`:
|
|
401
714
|
|
|
402
715
|
```ts
|
|
716
|
+
import { createGateRequestFingerprint } from "@genesis-tech/genesispay-seller";
|
|
717
|
+
|
|
403
718
|
const forecastGate = genesispay.products.gate("prod_...");
|
|
404
719
|
|
|
405
720
|
export async function POST(request: Request) {
|
|
406
721
|
const rawBody = await request.clone().text();
|
|
407
722
|
validateForecastJson(rawBody); // invalid requests never create a payment attempt
|
|
408
723
|
|
|
409
|
-
|
|
724
|
+
const expectedForRequest = {
|
|
725
|
+
...expectedForecastContract,
|
|
726
|
+
delivery: {
|
|
727
|
+
...expectedForecastContract.delivery,
|
|
728
|
+
gate: {
|
|
729
|
+
...expectedForecastContract.delivery.gate,
|
|
730
|
+
fingerprint: await createGateRequestFingerprint(request),
|
|
731
|
+
},
|
|
732
|
+
},
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
return forecastGate.protect(request, expectedForRequest, async (_request, purchase) => {
|
|
410
736
|
const cached = await readForecast(purchase.payment.attemptId);
|
|
411
737
|
if (cached) return Response.json(cached);
|
|
412
738
|
|
|
@@ -417,19 +743,95 @@ export async function POST(request: Request) {
|
|
|
417
743
|
}
|
|
418
744
|
```
|
|
419
745
|
|
|
420
|
-
|
|
746
|
+
`protect` validates the method, canonical resource URL, and request fingerprint
|
|
747
|
+
against the expected immutable contract before negotiation. Its challenge sends
|
|
748
|
+
that complete contract to GenesisPay, which compares it with the exact frozen
|
|
749
|
+
payable link before creating an attempt or returning a `402 Payment Required`;
|
|
750
|
+
the SDK never reconstructs authority from mutable catalogue presentation.
|
|
421
751
|
GenesisPay creates a pending attempt before that response and advertises a
|
|
422
752
|
reserved `gp_attempt` value in the x402 resource URL. On the signed retry, the
|
|
423
753
|
SDK sends only the request fingerprint to GenesisPay; it never sends forecast
|
|
424
|
-
inputs.
|
|
425
|
-
|
|
426
|
-
|
|
754
|
+
inputs. After settlement it retrieves strict evidence by attempt ID and invokes
|
|
755
|
+
the handler only after every authority field matches. If evidence is unavailable
|
|
756
|
+
it returns a recoverable `503` carrying `GENESISPAY-Payment-Attempt-Id` and
|
|
757
|
+
preserves a matching `PAYMENT-RESPONSE`. A transient response is `retryable: true` and
|
|
758
|
+
carries `Retry-After: 2`; a permanent inconsistency is `retryable: false` and
|
|
759
|
+
deliberately carries no retry instruction. The gate transport also negotiates
|
|
760
|
+
`GENESISPAY-Version: 2026-08-26`, while unversioned 0.x gate traffic keeps its
|
|
761
|
+
legacy wire contract. If the settlement connection drops after commit, the SDK
|
|
762
|
+
recovers the untrusted attempt locator from the signed x402 payload and returns
|
|
763
|
+
the same retryable 503; only `fulfillment.verify` can turn that locator into
|
|
764
|
+
authority. Handlers may execute more than once and must keep their own result
|
|
765
|
+
cache.
|
|
766
|
+
|
|
767
|
+
An interrupted verification response stream is transient; a completed malformed
|
|
768
|
+
evidence response is permanent. A settlement naming a different attempt drops
|
|
769
|
+
that unrelated receipt and reports `settlement_outcome_unknown` with the signed
|
|
770
|
+
attempt locator. Missing strict settlement metadata or a negative
|
|
771
|
+
`not_found`/`not_confirmed` verification likewise cannot claim confirmation.
|
|
772
|
+
Only independently verified evidence allows the handler to run.
|
|
773
|
+
|
|
774
|
+
You may retry the original signed request after its authorization expires if
|
|
775
|
+
that attempt was already confirmed. GenesisPay verifies the persisted attempt,
|
|
776
|
+
signature and original on-chain payment before replaying success; it does not
|
|
777
|
+
broadcast or collect a fee again. An unpaid expired authorization remains
|
|
778
|
+
rejected. Keep the same attempt locator and signature when recovering a payment.
|
|
779
|
+
Failed/expired attempts are terminal even if their signature is still valid or
|
|
780
|
+
the original link has been archived (`409 attempt_failed` / `attempt_expired`).
|
|
427
781
|
|
|
428
782
|
`products.list({ includeArchived: true })`, `products.retrieve`,
|
|
429
783
|
`products.archive` complete the namespace. Archiving stops **new** link mints;
|
|
430
784
|
the existing canonical link stays payable. The price is copied onto the link
|
|
431
785
|
at mint — a later catalogue edit never changes what a buyer already sees.
|
|
432
786
|
|
|
787
|
+
#### Prepare requests for body-priced resources
|
|
788
|
+
|
|
789
|
+
Before an agent signs, it asks your resource to freeze the exact settlement
|
|
790
|
+
plan: a request carrying `GENESISPAY-Settlement-Prepare: 1`. A client that
|
|
791
|
+
puts its plan parameters in a second header,
|
|
792
|
+
`GENESISPAY-Settlement-Prepare-Params` (base64 JSON
|
|
793
|
+
`{ payer, idempotencyKey, feeMode, authority? }`, decoded by
|
|
794
|
+
`decodeSettlementPrepareParamsHeader` from `@genesis-tech/genesispay-protocol`),
|
|
795
|
+
sends that preparation as a **repeat of the purchase request** — same method,
|
|
796
|
+
URL (plus the reserved `gp_attempt` query), `content-type` and body.
|
|
797
|
+
|
|
798
|
+
**Rollout:** the GenesisPay agent engine does not send the params header yet;
|
|
799
|
+
that is a server follow-up. Until it ships, agents send the legacy form
|
|
800
|
+
described below, so a body-validated route must still accept the legacy
|
|
801
|
+
preparation body — or hand any request carrying
|
|
802
|
+
`GENESISPAY-Settlement-Prepare: 1` to `protect` before its own validation.
|
|
803
|
+
|
|
804
|
+
For a client that sends the header, your route needs no special case. Parse and
|
|
805
|
+
validate the body as for any purchase — read it from `request.clone()` (or pass
|
|
806
|
+
a re-created `Request`) so the gate can still read the body and recompute the
|
|
807
|
+
fingerprint; a consumed body answers `422 { code: "invalid_request" }` — select
|
|
808
|
+
the gate for it (for example the generic gate for a
|
|
809
|
+
request tier, or the product whose registered resource serves that tier) and
|
|
810
|
+
call `protect` or the wrapped handler. A body-priced API needs one gate
|
|
811
|
+
resource per price: a product gate is unique per method and resource URL, so
|
|
812
|
+
each tier is its own registered resource (or its own generic gate). The gate
|
|
813
|
+
recognises the preparation by its header, never runs your handler for it, and
|
|
814
|
+
answers with the plan:
|
|
815
|
+
|
|
816
|
+
- **Product gates** recompute the method, canonical resource URL and request
|
|
817
|
+
fingerprint and require them to equal your expected gate intent, exactly as
|
|
818
|
+
for the challenge and the signed retry. A preparation for a different request
|
|
819
|
+
answers `422 { code: "contract_mismatch", mismatches }` and nothing reaches
|
|
820
|
+
GenesisPay. The forwarded `prepare` action is unchanged.
|
|
821
|
+
- **The generic gate** (`createPaymentGate`) prices by configuration and binds
|
|
822
|
+
no request fingerprint; it reads the parameters from the header and treats
|
|
823
|
+
the body as opaque purchase content.
|
|
824
|
+
- A present but malformed params header answers
|
|
825
|
+
`422 { code: "invalid_request" }`; the gate never falls back to the body.
|
|
826
|
+
|
|
827
|
+
Without the params header, both gates keep the legacy form, in which the body
|
|
828
|
+
carries the plan parameters. It stays supported for GET resources, hosted
|
|
829
|
+
payment links and the current agent engine — but a route that rejects any body
|
|
830
|
+
other than its own purchase schema will refuse it, so such a route must
|
|
831
|
+
recognise the prepare header before its own validation until clients send the
|
|
832
|
+
header form. The decision is
|
|
833
|
+
recorded in ADR-0083 of the GenesisPay repository.
|
|
834
|
+
|
|
433
835
|
### Testing the paid path
|
|
434
836
|
|
|
435
837
|
```ts
|
|
@@ -439,8 +841,9 @@ const session = await genesispay.checkout.simulatePayment(publicId);
|
|
|
439
841
|
|
|
440
842
|
Test keys only — a `gp_sk_live_…` key gets a 403, and on a mainnet deployment the
|
|
441
843
|
endpoint does not exist at all. The resulting attempt has `txHash: null` (no
|
|
442
|
-
transaction happened) and `simulated: true
|
|
443
|
-
|
|
844
|
+
transaction happened) and `simulated: true` in tolerant display responses. Never
|
|
845
|
+
use that response to fulfil: strict `fulfillment.verify` returns the normal
|
|
846
|
+
negative reason `simulated`, while a pending real attempt can also have no hash.
|
|
444
847
|
|
|
445
848
|
### Fulfilment guide
|
|
446
849
|
|
|
@@ -452,15 +855,77 @@ handler run never double-delivers.
|
|
|
452
855
|
|
|
453
856
|
| Product | Fulfil on | Deduplicate by |
|
|
454
857
|
|---|---|---|
|
|
455
|
-
| Standalone single-use checkout |
|
|
456
|
-
|
|
|
457
|
-
| Redirect product |
|
|
458
|
-
| Product-backed gate |
|
|
858
|
+
| Standalone single-use checkout | strict `fulfillment.verify` by link or attempt | confirmed `payment.attemptId` |
|
|
859
|
+
| Product without digital delivery | strict `fulfillment.verify` by attempt | `payment.attemptId` — never link-level `paid` |
|
|
860
|
+
| Redirect product | strict `fulfillment.verify` by entitlement or attempt | `payment.attemptId` |
|
|
861
|
+
| Product-backed gate | strict evidence returned after confirmed settlement | `payment.attemptId` |
|
|
862
|
+
|
|
863
|
+
`payment.fulfilled` is the canonical notification. Its `data` is the strict
|
|
864
|
+
FulfillmentEvidence wire shape plus explicit nullable `clientReferenceId`; its
|
|
865
|
+
envelope has `apiVersion: "2026-08-26"` and `livemode`. Simulation does not emit
|
|
866
|
+
it. `constructEvent` verifies raw bytes before parsing and validates known evidence
|
|
867
|
+
fields, preserving integer amount strings. It does **not** return VerifiedPayment:
|
|
868
|
+
always call authenticated `fulfillment.verify({ locator, expected })` afterward.
|
|
869
|
+
The name does not claim that your application has delivered anything.
|
|
870
|
+
|
|
871
|
+
`livemode` is not present on every event type. Do not discard an event because
|
|
872
|
+
`!event.livemode` is true: an absent field also passes that check. For
|
|
873
|
+
`payment.fulfilled`, `livemode: false` means Base Sepolia, and
|
|
874
|
+
`data.simulation.simulated` is explicitly false. Network and simulation are
|
|
875
|
+
separate facts; never substitute `livemode` for the event's simulation field.
|
|
876
|
+
Use authenticated `fulfillment.verify` before granting credits or fulfilling
|
|
877
|
+
an order.
|
|
878
|
+
|
|
879
|
+
Persist a purchase intent (account, credits, expected contract) before checkout.
|
|
880
|
+
Use its ID as `clientReferenceId` and the checkout idempotency key. Store the
|
|
881
|
+
returned link ID and require `verified.payment.linkId` to match it before credit.
|
|
882
|
+
If the webhook precedes that response, keep it pending and recover the same
|
|
883
|
+
checkout; never assign a payment to an account from webhook metadata alone.
|
|
884
|
+
|
|
885
|
+
New event IDs are stable across endpoints, retries and explicit replay. Dedupe
|
|
886
|
+
inbox processing by event ID, but **atomically claim verified attempt ID and
|
|
887
|
+
purchase intent with the credit balance/ledger update** across every trigger.
|
|
888
|
+
Different event types, browser return and reconciliation must converge there.
|
|
889
|
+
Keep a conflicting binding for investigation; never grant a second credit.
|
|
890
|
+
Persist the inbox before acknowledging 2xx and let a worker retry verification.
|
|
891
|
+
|
|
892
|
+
There are nine sends per delivery cycle: initial plus 30s, 2m, 5m, 15m, 1h, 6h,
|
|
893
|
+
12h, 24h delays (±10% jitter), maximum72h. Minute scheduling rounds due work up to
|
|
894
|
+
the next tick. Only explicit audited operator replay opens another cycle, with
|
|
895
|
+
identical body and event ID. Acknowledgement loss permits duplicates. Outbox
|
|
896
|
+
producers/worker must be deployed and measured before relying on these targets.
|
|
897
|
+
|
|
898
|
+
### Recover missing notifications
|
|
899
|
+
|
|
900
|
+
```ts
|
|
901
|
+
let cursor: string | undefined;
|
|
902
|
+
do {
|
|
903
|
+
const page = await genesispay.fulfillment.listAttempts({
|
|
904
|
+
clientReferenceId: intent.id,
|
|
905
|
+
confirmedAfter: intent.createdAt,
|
|
906
|
+
limit: 50,
|
|
907
|
+
cursor,
|
|
908
|
+
});
|
|
909
|
+
for (const candidate of page.data) {
|
|
910
|
+
const verified = await genesispay.fulfillment.verify({
|
|
911
|
+
locator: { attemptId: candidate.attemptId }, expected: intent.expected,
|
|
912
|
+
});
|
|
913
|
+
if (verified.verified && verified.payment.linkId === intent.linkId) {
|
|
914
|
+
// Your shared transaction claims attempt + intent and writes credits.
|
|
915
|
+
await creditVerifiedPurchaseOnce(intent, verified.payment);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
cursor = page.nextCursor ?? undefined;
|
|
919
|
+
} while (cursor);
|
|
920
|
+
```
|
|
459
921
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
922
|
+
Listing requires the strict API version and seller key and enforces seller/mode
|
|
923
|
+
scope. Optional exact reference (max200), inclusive ISO confirmedAfter/Before,
|
|
924
|
+
limit1..100(default50) and opaque cursor are supported. Keep filters unchanged
|
|
925
|
+
between pages. A cursor fixes the upper bound and preserves database microseconds.
|
|
926
|
+
Repeat scans for late commits; include abandoned, expired and cancelled browser
|
|
927
|
+
flows. `authorityVersion: null` is a historical diagnostic, never permission to
|
|
928
|
+
credit. No listing row is authority, even when it names a confirmed attempt.
|
|
464
929
|
|
|
465
930
|
### Correlation, and the `cs` query parameter
|
|
466
931
|
|
|
@@ -492,18 +957,27 @@ The `returnUrl`/`cancelUrl` limits and the amount-conflict check are validated
|
|
|
492
957
|
remaining limits (`metadata`, `clientReferenceId`) are enforced server-side and
|
|
493
958
|
fail the `checkout.create` call with a 422.
|
|
494
959
|
|
|
495
|
-
### Note on `paid` for
|
|
960
|
+
### Note on `paid` for product links
|
|
496
961
|
|
|
497
962
|
`session.paid` is derived from `confirmedPaymentCount > 0`. For a `reusable` link
|
|
498
963
|
that counter only ever grows, so `paid` stays `true` from the first payment
|
|
499
964
|
onward — it answers "has this link ever been paid", not "has *this* buyer paid".
|
|
500
|
-
For per-
|
|
501
|
-
`confirmedPaymentCount` as
|
|
965
|
+
For per-purchase fulfilment on a product link, use a webhook as a trigger and then
|
|
966
|
+
strictly verify its attempt ID. Never use `confirmedPaymentCount` as authority.
|
|
502
967
|
|
|
503
968
|
Options: `baseUrl` (override the mode default — https, http only for localhost;
|
|
504
969
|
both modes default to the GenesisPay facilitator, so it is optional), `configTtlMs`
|
|
505
970
|
(seller-config cache TTL, default 5 min), `expectedPayTo` (recommended for `live`
|
|
506
971
|
keys — a local pin that fail-closes if the resolved wallet ever differs), `fetchFn`.
|
|
972
|
+
The pin also applies to `checkout.retrieve`, `products.assertContract`,
|
|
973
|
+
`products.createCheckout`, `products.gate(...).protect`, and `fulfillment.verify`.
|
|
974
|
+
Checkout retrieval checks the raw destination before its display mapper runs;
|
|
975
|
+
strict methods refuse a conflicting expected destination before a network request.
|
|
976
|
+
Missing or conflicting destinations fail closed. An explicit expected contract
|
|
977
|
+
cannot override the client pin, including on historical verification.
|
|
978
|
+
After a wallet rotation, reconcile old payments with a separate client pinned
|
|
979
|
+
to the original destination recorded in your immutable order contract. Keep the
|
|
980
|
+
current client pinned to the new wallet; neither pin rewrites payment history.
|
|
507
981
|
The client fails **closed**: an unreachable backend returns `503` and a seller with
|
|
508
982
|
no wallet returns a `402` `payment_not_configured` — the paid handler never runs
|
|
509
983
|
without a valid destination. It also refuses to advertise a wallet whose network
|
|
@@ -596,7 +1070,10 @@ The same wrapped handler drops straight into `Bun.serve({ fetch: gated })`.
|
|
|
596
1070
|
|
|
597
1071
|
`gate.wrap(handler, { verifySettlement })` requires a settlement hook. Use the
|
|
598
1072
|
built-in `genesisPaySettlement({ facilitatorBaseUrl, apiKey })`, which POSTs the
|
|
599
|
-
|
|
1073
|
+
plan-aware prepare request before an agent signs, then forwards the signed
|
|
1074
|
+
authorization with its immutable settlement-plan id. Clients that omit the
|
|
1075
|
+
prepare handshake are refused before broadcast with `settlement_plan_required`.
|
|
1076
|
+
The hook sends preparation and settlement to GenesisPay's facilitator. GenesisPay
|
|
600
1077
|
broadcasts the EIP-3009 authorization on-chain, verifies the USDC transfer,
|
|
601
1078
|
and returns the receipt. `facilitatorBaseUrl` is optional and defaults to the
|
|
602
1079
|
public development facilitator (`DEFAULT_FACILITATOR_BASE_URL`,
|
|
@@ -630,3 +1107,105 @@ const verifySettlement: VerifySettlement = async ({ payment, requirement }) => {
|
|
|
630
1107
|
your `verifySettlement` hook.
|
|
631
1108
|
- Get a seller API key (`gp_sk_...`) from your GenesisPay dashboard under
|
|
632
1109
|
Developers.
|
|
1110
|
+
|
|
1111
|
+
### Beta: new mandate authority answers 409
|
|
1112
|
+
|
|
1113
|
+
During the GenesisPay beta, a deployment may lock the creation of **new** mandate
|
|
1114
|
+
authority. While it is locked, `mandates.create`, the `contract_mandate_v1`
|
|
1115
|
+
helpers (`mandates.createContractSubscription`, `mandates.createContractPerUse`,
|
|
1116
|
+
`mandates.proposeContractUsage`) and `plans.create` reject with HTTP `409` and
|
|
1117
|
+
code `mandate_authority_locked_for_beta`. There is no `Retry-After` — waiting
|
|
1118
|
+
does not change the answer, so treat it as a capability that is off rather than a
|
|
1119
|
+
transient failure.
|
|
1120
|
+
|
|
1121
|
+
`mandates.submitContractUsage`, `mandates.getContractUsage`, revocation, every
|
|
1122
|
+
list/read, and the whole x402 payment-gate path are unaffected, as is any mandate
|
|
1123
|
+
a payer already approved. A retry of a request made before the lock still returns
|
|
1124
|
+
its original charge, mandate or payment identity — keep using the SAME
|
|
1125
|
+
idempotency key, exactly as you would without the lock. In particular a
|
|
1126
|
+
`mandates.charge` retry whose key already names a charge still comes back with
|
|
1127
|
+
that charge (settled, or `submitted` with its locator) rather than the 409, so a
|
|
1128
|
+
`409 mandate_authority_locked_for_beta` always means no charge exists for that
|
|
1129
|
+
key. Never retry with a fresh key to work around it.
|
|
1130
|
+
|
|
1131
|
+
### Experimental gated contract subscriptions
|
|
1132
|
+
|
|
1133
|
+
`mandates.createContractSubscription` explicitly requests the two-signature
|
|
1134
|
+
`contract_mandate_v1` protocol. It requires an ISO expiry and validates both
|
|
1135
|
+
returned signing payloads. This capability is off by default; the reviewed
|
|
1136
|
+
contract registry must permit the deployment. Mainnet also requires legal/audit
|
|
1137
|
+
and completed legacy-authority cutover evidence.
|
|
1138
|
+
|
|
1139
|
+
```ts
|
|
1140
|
+
const proposal = await genesispay.mandates.createContractSubscription({
|
|
1141
|
+
payerWallet, allowance: "108", capPerCharge: "9", amountPerPeriod: "9",
|
|
1142
|
+
periodDays: 30, validUntil: "2027-09-01T00:00:00Z",
|
|
1143
|
+
});
|
|
1144
|
+
// In the payer frontend, sign approval.termsTypedData, then approval.permitTypedData.
|
|
1145
|
+
// POST /api/v1/mandates/:id/activate:
|
|
1146
|
+
// { protocol: "contract_mandate_v1", termsSignature, permitSignature }
|
|
1147
|
+
```
|
|
1148
|
+
|
|
1149
|
+
HTTP202 is acceptance, not activation or payment confirmation. Retry the same
|
|
1150
|
+
signatures after an unknown HTTP outcome. A409 `mandate_proposal_stale` requires a
|
|
1151
|
+
new proposal and two new approvals. The legacy `mandates.create` method keeps its
|
|
1152
|
+
one-permit contract and cannot silently switch protocols.
|
|
1153
|
+
|
|
1154
|
+
`mandates.revoke(id)` requests local cancellation for contract mandates; inspect
|
|
1155
|
+
`revocationStatus`. The payer signs `revocationTypedData` from the returned API
|
|
1156
|
+
mandate and any relay can POST `{ protocol: "contract_mandate_v1", signature }`
|
|
1157
|
+
to `/api/v1/mandates/:id/revoke-signed`. Only `confirmed` means the permanent
|
|
1158
|
+
on-chain revocation exists. An independent relay may instead call the bound
|
|
1159
|
+
executor's `revokeWithSignature(payer, mandateId, signature)`; the payer can call
|
|
1160
|
+
`revoke(mandateId)` directly. Neither path requires an API session. Do not use
|
|
1161
|
+
`approve(0)` as proof that old signed permits are invalid.
|
|
1162
|
+
|
|
1163
|
+
Contract subscription renewals are admitted by the worker or cron and become paid
|
|
1164
|
+
only after canonical atomic receipt verification. First charge is eligible at
|
|
1165
|
+
activation; later charges wait the signed interval after successful settlement.
|
|
1166
|
+
Downtime does not create catch-up charges. A confirmed revert may receive a new
|
|
1167
|
+
attempt only after exact receipt and unused-authority verification.
|
|
1168
|
+
|
|
1169
|
+
Contract billing webhooks add `protocol: "contract_mandate_v1"`, `contractEvent`
|
|
1170
|
+
(immutable chain/block/log, logical identity, sequence and split amounts), and
|
|
1171
|
+
`mandateStateContext: { kind: "projection_snapshot", observedAt }`. `occurredAt`
|
|
1172
|
+
is the event's block time; `mandate` is the state when that event was projected,
|
|
1173
|
+
which can be later. Deliveries may arrive out of order: an old `mandate.active`
|
|
1174
|
+
event can carry an already revoked snapshot. Order event facts by block/log and
|
|
1175
|
+
retrieve current state before enabling access. Deduplicate the envelope `id`;
|
|
1176
|
+
replays preserve its original bytes, including the original snapshot.
|
|
1177
|
+
|
|
1178
|
+
### Experimental gated signed per-use mandates
|
|
1179
|
+
|
|
1180
|
+
`mandates.createContractPerUse({ payerWallet, allowance, capPerCharge, validUntil })`
|
|
1181
|
+
returns the same two bounded approvals as the contract subscription API. It has
|
|
1182
|
+
no recurring amount or interval. After the payer signs both approvals and the
|
|
1183
|
+
activation confirms, each usage request requires its own payer signature:
|
|
1184
|
+
|
|
1185
|
+
```ts
|
|
1186
|
+
const charge = await genesispay.mandates.proposeContractUsage({
|
|
1187
|
+
mandate: approvedMandate, // the saved createContractPerUse result
|
|
1188
|
+
amountMinor: "80000", // gross integer minor units, including any fee
|
|
1189
|
+
idempotencyKey: "forecast-request-001",
|
|
1190
|
+
resourceUrl: "https://example.com/forecast",
|
|
1191
|
+
});
|
|
1192
|
+
// The payer's wallet signs charge.chargeTypedData. The seller SDK never signs.
|
|
1193
|
+
const accepted = await genesispay.mandates.submitContractUsage(charge, payerSignature);
|
|
1194
|
+
const current = await genesispay.mandates.getContractUsage(accepted);
|
|
1195
|
+
// Fulfill only after current.status === "settled", verified against your order.
|
|
1196
|
+
```
|
|
1197
|
+
|
|
1198
|
+
The SDK independently builds the charge EIP-712 message from the saved mandate
|
|
1199
|
+
consent and exact requested gross. It checks chain, executor, mandate, fee,
|
|
1200
|
+
request metadata and signing fields. Submission and polling preserve the original
|
|
1201
|
+
charge identity and deadline. HTTP202 means pending; queue acceptance is not a
|
|
1202
|
+
confirmed payment. A retry uses the same idempotency key and saved proposal.
|
|
1203
|
+
Changing a request under that key returns a conflict, including after failure.
|
|
1204
|
+
|
|
1205
|
+
The server retains the proposal before a wallet prompt. If no signature arrives,
|
|
1206
|
+
it closes the source only after finalized proof that the charge is unused and
|
|
1207
|
+
its authority expired or was revoked. The old `mandates.charge` and metering gate
|
|
1208
|
+
are legacy-only and refuse contract mandates; they cannot silently replace the
|
|
1209
|
+
required signature. These contract APIs remain gated and unreleased on Mainnet.
|
|
1210
|
+
Agent policy/signing integration and per-use successors after a mined revert
|
|
1211
|
+
remain separate pending implementation work.
|