@siglume/direct-request-payment 0.4.18 → 0.4.20

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 CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.20 - 2026-06-20
4
+
5
+ Close the v0.4.19 public onboarding safety review.
6
+
7
+ - Fixed generated Express/FastAPI webhook handling so webhook event ids are
8
+ recorded as processed only after the order update or durable review write
9
+ succeeds. A retry after a mid-handler failure is no longer discarded as a
10
+ duplicate.
11
+ - Added stable checkout attempts/nonces to generated routes and starters so a
12
+ retry or double click reuses the active attempt instead of creating a fresh
13
+ timestamp nonce.
14
+ - Split Express checkout and webhook mounting helpers so production apps can
15
+ mount the raw-body webhook before global `express.json()`.
16
+ - Strengthened `siglume-check readiness` to require `SIGLUME_WEBHOOK_SECRET`,
17
+ active billing, matching active webhook subscription, subscribed
18
+ `direct_payment.confirmed`, matching signing-secret hint, Hosted Checkout
19
+ probe, and signed webhook delivery probe.
20
+ - Added webhook subscription/test-delivery/delivery-list client helpers in
21
+ TypeScript and Python.
22
+ - Made generated 10-minute routes Standard-only by default; Micro / Nano now
23
+ require explicit delayed-settlement reconciliation before fulfillment.
24
+ - Clarified Micro / Nano unsettled-exposure scope and terminal states across
25
+ pricing, announcement, lifecycle, troubleshooting, and API reference docs.
26
+ - Added readiness negative tests and webhook API client tests.
27
+
28
+ ## 0.4.19 - 2026-06-20
29
+
30
+ Make the 10-minute integration path a real product-integration path instead of
31
+ a separate demo.
32
+
33
+ - Added npm and PyPI CLI bins `siglume-sdrp` and `siglume-check`.
34
+ - Added `siglume-check readiness` to validate merchant token, merchant key,
35
+ HTTPS origin/webhook configuration, Standard-band probe amount, merchant
36
+ account/billing readiness, and Hosted Checkout availability through an unpaid
37
+ checkout-session probe.
38
+ - Added `siglume-sdrp init express` for npm and `siglume-sdrp init fastapi` for
39
+ npm/PyPI to copy framework-specific checkout/webhook route files into an
40
+ existing product.
41
+ - Added Express and FastAPI integration templates with order-store adapter
42
+ interfaces so teams can wire SDRP into their real order database instead of
43
+ starting from an isolated sample app.
44
+ - Reframed the 10-minute guide around existing-product integration and moved
45
+ the readiness check before any coding.
46
+
3
47
  ## 0.4.18 - 2026-06-19
4
48
 
5
49
  Developer-onboarding cleanup for the v0.4.17 public review.
package/README.md CHANGED
@@ -80,11 +80,26 @@ card-style "instant" checkout for first-time buyers.
80
80
 
81
81
  ## Fast Path
82
82
 
83
- If your merchant account, Hosted Checkout enablement, billing mandate, HTTPS
84
- webhook URL, and buyer test wallet are already ready, use
85
- [10-Minute First Test Payment](./docs/quickstart-10-minutes.md) to connect one
86
- Standard Payment test. That page is intentionally scoped to a first test
87
- payment, not a production launch.
83
+ Use [10-Minute Product Integration](./docs/quickstart-10-minutes.md) to add
84
+ Hosted Checkout routes to an existing Express or FastAPI product. The path is
85
+ CLI-first:
86
+
87
+ ```bash
88
+ npm install @siglume/direct-request-payment
89
+ npx siglume-check readiness
90
+ npx siglume-sdrp init express --target src/siglume
91
+ ```
92
+
93
+ or:
94
+
95
+ ```bash
96
+ pip install siglume-direct-request-payment
97
+ siglume-sdrp init fastapi --target app/siglume
98
+ ```
99
+
100
+ The readiness command checks account, billing, origin, webhook, and Hosted
101
+ Checkout availability before you write checkout code. It also confirms the
102
+ webhook subscription and signed test delivery when API probes are enabled.
88
103
 
89
104
  Before implementation, confirm Hosted Checkout readiness in
90
105
  [Troubleshooting](./docs/troubleshooting.md#hosted-checkout-readiness). For
@@ -103,10 +118,10 @@ fulfilling orders.
103
118
 
104
119
  ## Use-Case Fit
105
120
 
106
- | Use case | Recommended path | 10-minute demo? | Production work still required |
121
+ | Use case | Recommended path | 10-minute integration path? | Production work still required |
107
122
  | --- | --- | --- | --- |
108
- | EC one-time Standard payment | Hosted Checkout | Yes, if prerequisites are ready | Durable order DB, webhook dedupe, refund/support process, monitoring |
109
- | Game consumables | Hosted Checkout or agent/API | Conditional | Idempotent entitlement grants, disconnect recovery, Micro / Nano unsettled-risk handling |
123
+ | EC one-time Standard payment | Hosted Checkout | Yes, with `siglume-check readiness` and `siglume-sdrp init` | Product DB adapter, refund/support process, monitoring |
124
+ | Game consumables | Hosted Checkout or agent/API | Conditional | Idempotent entitlement grants, disconnect recovery, Micro / Nano settlement reconciliation and past-due handling |
110
125
  | Paid API / AtoA | Direct API or Siglume marketplace tool | Conditional | Request idempotency, buyer auth context, reconciliation |
111
126
  | SaaS subscription | Recurring challenge plus raw API | No | Renewal, cancellation, failed renewal, plan-change lifecycle |
112
127
  | Scheduled autopay | Recurring challenge plus schedule token | No | Scheduler, token custody, budget failure handling |
@@ -157,8 +172,8 @@ redirect(session.checkout_url); // -> https://siglume.com/pay/<session_id>
157
172
 
158
173
  // 3. Handle the signed direct_payment.confirmed webhook. Use
159
174
  // classifyDirectPaymentConfirmation(event). Fulfill Standard only for
160
- // standard_settled; treat metered_usage_accepted as fulfilled-unsettled
161
- // until the later metered_batch_settled event arrives.
175
+ // standard_settled. Do not fulfill metered_usage_accepted unless you have
176
+ // explicitly enabled Micro / Nano settlement reconciliation.
162
177
  // Poll merchant.getCheckoutSession(session.session_id) if you also want to
163
178
  // show status in your own UI.
164
179
  ```
@@ -192,8 +207,8 @@ redirect(session["checkout_url"]) # -> https://siglume.com/pay/<session_id>
192
207
 
193
208
  # 3. Handle the signed direct_payment.confirmed webhook. Use
194
209
  # classify_direct_payment_confirmation(event). Fulfill Standard only for
195
- # standard_settled; treat metered_usage_accepted as fulfilled-unsettled
196
- # until the later metered_batch_settled event arrives.
210
+ # standard_settled. Do not fulfill metered_usage_accepted unless you have
211
+ # explicitly enabled Micro / Nano settlement reconciliation.
197
212
  # Poll merchant.get_checkout_session(session["session_id"]) if you also want
198
213
  # to show status in your own UI.
199
214
  ```
@@ -617,7 +632,8 @@ if (event.type === "direct_payment.confirmed") {
617
632
  } else if (confirmation.kind === "standard_settled") {
618
633
  // Mark the order paid once if event.data.challenge_hash/order mapping matches.
619
634
  } else if (confirmation.kind === "metered_usage_accepted") {
620
- // Mark fulfilled-but-unsettled after matching confirmation.challenge_hash.
635
+ // Default Standard-only integrations should not fulfill this.
636
+ // Enable Micro / Nano only with settlement reconciliation and past-due handling.
621
637
  } else {
622
638
  // Route confirmation.reason to manual review. Do not mark paid or fulfilled.
623
639
  }
@@ -648,7 +664,8 @@ if verified["event"]["type"] == "direct_payment.confirmed":
648
664
  # Mark the order paid once if event.data.challenge_hash/order mapping matches.
649
665
  pass
650
666
  elif confirmation["kind"] == "metered_usage_accepted":
651
- # Mark fulfilled-but-unsettled after matching confirmation["challenge_hash"].
667
+ # Default Standard-only integrations should not fulfill this.
668
+ # Enable Micro / Nano only with settlement reconciliation and past-due handling.
652
669
  pass
653
670
  else:
654
671
  # Route confirmation["reason"] to manual review. Do not mark paid or fulfilled.
@@ -707,7 +724,7 @@ handling and support escalation.
707
724
  ## Documentation
708
725
 
709
726
  - [Merchant quickstart](./docs/merchant-quickstart.md)
710
- - [10-minute first test payment](./docs/quickstart-10-minutes.md)
727
+ - [10-minute product integration](./docs/quickstart-10-minutes.md)
711
728
  - [Payment lifecycle](./docs/payment-lifecycle.md)
712
729
  - [Troubleshooting](./docs/troubleshooting.md)
713
730
  - [API reference](./docs/api-reference.md)
@@ -0,0 +1,398 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync } from "node:fs";
4
+ import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ import {
9
+ DirectRequestPaymentMerchantClient,
10
+ HostedCheckoutNotAvailableError,
11
+ SiglumeApiError,
12
+ } from "../dist/index.js";
13
+
14
+ const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
15
+
16
+ main().catch((error) => {
17
+ console.error(`siglume-sdrp: ${error instanceof Error ? error.message : String(error)}`);
18
+ process.exitCode = 1;
19
+ });
20
+
21
+ async function main() {
22
+ loadDotEnv();
23
+ const [command = "help", ...args] = process.argv.slice(2);
24
+ if (command === "help" || command === "--help" || command === "-h") {
25
+ printHelp();
26
+ return;
27
+ }
28
+ if (command === "readiness" || command === "doctor") {
29
+ await readiness(parseArgs(args));
30
+ return;
31
+ }
32
+ if (command === "init") {
33
+ await init(args);
34
+ return;
35
+ }
36
+ throw new Error(`Unknown command: ${command}`);
37
+ }
38
+
39
+ function printHelp() {
40
+ console.log(`Siglume SDRP integration CLI
41
+
42
+ Usage:
43
+ siglume-check readiness --merchant <key> --origin <https://shop.example> --webhook-url <https://api.example/siglume/webhook>
44
+ siglume-sdrp init express --target src/siglume
45
+ siglume-sdrp init fastapi --target app/siglume
46
+
47
+ Readiness options:
48
+ --merchant <key> Merchant key. Defaults to SIGLUME_DIRECT_PAYMENT_MERCHANT.
49
+ --origin <origin> Public shop origin. Defaults to SHOP_PUBLIC_ORIGIN.
50
+ --webhook-url <url> Public webhook URL. Defaults to SHOP_WEBHOOK_URL.
51
+ --currency <JPY|USD> Probe currency. Defaults to SIGLUME_DIRECT_PAYMENT_TEST_CURRENCY or JPY.
52
+ --amount-minor <amount> Standard-band probe amount. Defaults to 501 for JPY, 301 for USD.
53
+ --base-url <url> Siglume API base URL. Defaults to SIGLUME_API_BASE or production.
54
+ --no-api Validate local config only; do not call Siglume.
55
+ --no-probe Partial API check only; readiness will not be reported as ready.
56
+ --json Print machine-readable JSON.
57
+ `);
58
+ }
59
+
60
+ function parseArgs(args) {
61
+ const out = { api: true, probe: true, json: false };
62
+ for (let i = 0; i < args.length; i += 1) {
63
+ const arg = args[i];
64
+ if (arg === "--no-api") {
65
+ out.api = false;
66
+ } else if (arg === "--no-probe") {
67
+ out.probe = false;
68
+ } else if (arg === "--json") {
69
+ out.json = true;
70
+ } else if (arg === "--force") {
71
+ out.force = true;
72
+ } else if (arg.startsWith("--")) {
73
+ const key = arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
74
+ const value = args[i + 1];
75
+ if (!value || value.startsWith("--")) {
76
+ throw new Error(`${arg} requires a value.`);
77
+ }
78
+ out[key] = value;
79
+ i += 1;
80
+ } else {
81
+ throw new Error(`Unexpected argument: ${arg}`);
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+
87
+ async function readiness(options) {
88
+ const checks = [];
89
+ const merchant = options.merchant || process.env.SIGLUME_DIRECT_PAYMENT_MERCHANT || "";
90
+ const origin = options.origin || process.env.SHOP_PUBLIC_ORIGIN || "";
91
+ const webhookUrl = options.webhookUrl || process.env.SHOP_WEBHOOK_URL || "";
92
+ const webhookSecret = process.env.SIGLUME_WEBHOOK_SECRET || "";
93
+ const token = process.env.SIGLUME_MERCHANT_AUTH_TOKEN || process.env.SIGLUME_AUTH_TOKEN || "";
94
+ const currency = normalizeCurrency(options.currency || process.env.SIGLUME_DIRECT_PAYMENT_TEST_CURRENCY || "JPY");
95
+ const amountMinor = Number(options.amountMinor || process.env.SIGLUME_DIRECT_PAYMENT_TEST_AMOUNT_MINOR || (currency === "USD" ? 301 : 501));
96
+
97
+ check(checks, "merchant_key", Boolean(merchant), "Set SIGLUME_DIRECT_PAYMENT_MERCHANT or pass --merchant.");
98
+ check(checks, "merchant_token", Boolean(token) && !token.startsWith("cli_"), "Set SIGLUME_MERCHANT_AUTH_TOKEN to a merchant Siglume bearer token, not a cli_ key.");
99
+ check(checks, "shop_origin", isHttpsOrigin(origin), "Set SHOP_PUBLIC_ORIGIN to an https origin, for example https://www.example.com.");
100
+ check(checks, "webhook_url", isHttpsUrl(webhookUrl), "Set SHOP_WEBHOOK_URL to a public https webhook URL.");
101
+ check(checks, "webhook_secret_present", Boolean(webhookSecret) && webhookSecret.startsWith("whsec_"), "Set SIGLUME_WEBHOOK_SECRET to the webhook signing secret returned by setupCheckout/setup_checkout.");
102
+ check(checks, "standard_probe_amount", isStandardAmount(currency, amountMinor), "Use a Standard-band probe amount: JPY 501+ or USD 301+ minor units.");
103
+
104
+ if (options.api && !hasFailures(checks)) {
105
+ const merchantClient = new DirectRequestPaymentMerchantClient({
106
+ auth_token: token,
107
+ base_url: options.baseUrl || process.env.SIGLUME_API_BASE,
108
+ });
109
+ let matchingWebhookSubscription = null;
110
+ try {
111
+ const merchantResponse = await merchantClient.getMerchant(merchant);
112
+ const account = merchantResponse.merchant_account || {};
113
+ check(checks, "merchant_exists", Boolean(account.merchant), "Run merchant setup before checkout.");
114
+ check(checks, "billing_mandate", Boolean(account.billing_mandate_id), "Complete the merchant billing mandate wallet approval.");
115
+ check(checks, "billing_status_active", activeLike(account.billing_status), `Billing status is ${account.billing_status || "unknown"}; it must be active before accepting payments.`);
116
+ warnIf(checks, "merchant_status", account.status && !activeLike(account.status), `Merchant status is ${account.status}; confirm it is allowed to accept payments.`);
117
+ } catch (error) {
118
+ check(checks, "merchant_api", false, apiErrorMessage(error, "Could not read the merchant account."));
119
+ }
120
+
121
+ if (!hasFailures(checks)) {
122
+ try {
123
+ const subscriptions = await merchantClient.listWebhookSubscriptions();
124
+ const activeSubscriptions = subscriptions.filter((subscription) => activeLike(subscription.status));
125
+ matchingWebhookSubscription = activeSubscriptions.find((subscription) => urlsEqual(subscription.callback_url, webhookUrl)) || null;
126
+ check(checks, "webhook_subscription_exists", activeSubscriptions.length > 0, "Create an active webhook subscription before checkout.");
127
+ check(checks, "webhook_callback_matches", Boolean(matchingWebhookSubscription), `No active webhook subscription points at ${webhookUrl}.`);
128
+ check(
129
+ checks,
130
+ "direct_payment_confirmed_subscribed",
131
+ Boolean(matchingWebhookSubscription) && includesEventType(matchingWebhookSubscription.event_types, "direct_payment.confirmed"),
132
+ "The matching webhook subscription must include direct_payment.confirmed.",
133
+ );
134
+ check(
135
+ checks,
136
+ "webhook_secret_matches_subscription_hint",
137
+ Boolean(matchingWebhookSubscription?.signing_secret_hint) && webhookSecret.endsWith(String(matchingWebhookSubscription.signing_secret_hint)),
138
+ "SIGLUME_WEBHOOK_SECRET does not match the signing_secret_hint for the matching subscription. Rotate or re-save the webhook secret.",
139
+ );
140
+ } catch (error) {
141
+ check(checks, "webhook_subscription_api", false, apiErrorMessage(error, "Could not read webhook subscriptions."));
142
+ }
143
+ }
144
+
145
+ if (!options.probe && !hasFailures(checks)) {
146
+ check(checks, "hosted_checkout_probe", false, "--no-probe skips Hosted Checkout and webhook delivery probes. Remove --no-probe for readiness.");
147
+ }
148
+
149
+ if (options.probe && !hasFailures(checks)) {
150
+ try {
151
+ const session = await merchantClient.createCheckoutSession({
152
+ merchant,
153
+ amount_minor: amountMinor,
154
+ currency,
155
+ nonce: `sdrp-readiness-${Date.now()}`,
156
+ success_url: `${origin}/siglume-readiness/success`,
157
+ cancel_url: `${origin}/siglume-readiness/cancel`,
158
+ metadata: { source: "siglume-sdrp-readiness" },
159
+ });
160
+ check(checks, "hosted_checkout", Boolean(session.checkout_url && session.challenge_hash), "Hosted Checkout did not return a checkout_url.");
161
+ } catch (error) {
162
+ const message = error instanceof HostedCheckoutNotAvailableError
163
+ ? "Hosted Checkout is not enabled for this merchant account. Ask Siglume to enable it before coding the human checkout path."
164
+ : apiErrorMessage(error, "Hosted Checkout probe failed. Check checkout_allowed_origins, currency, amount, and billing mandate.");
165
+ check(checks, "hosted_checkout", false, message);
166
+ }
167
+ }
168
+
169
+ if (options.probe && !hasFailures(checks)) {
170
+ await checkWebhookDeliveryProbe(checks, merchantClient, {
171
+ merchant,
172
+ subscription: matchingWebhookSubscription,
173
+ });
174
+ }
175
+ }
176
+
177
+ const ok = !hasFailures(checks);
178
+ if (options.json) {
179
+ console.log(JSON.stringify({ ok, checks }, null, 2));
180
+ } else {
181
+ for (const item of checks) {
182
+ const mark = item.status === "pass" ? "OK" : item.status === "warn" ? "WARN" : "FAIL";
183
+ console.log(`${mark} ${item.name}: ${item.message}`);
184
+ }
185
+ if (ok && !options.api) {
186
+ console.log("Local config checks passed. API, Hosted Checkout, and webhook delivery readiness were not verified.");
187
+ } else {
188
+ console.log(ok ? "Ready for 10-minute SDRP integration." : "Not ready. Fix the FAIL items before coding checkout.");
189
+ }
190
+ }
191
+ if (!ok) {
192
+ process.exitCode = 1;
193
+ }
194
+ }
195
+
196
+ async function init(args) {
197
+ const framework = args[0];
198
+ const parsed = parseArgs(args.slice(1));
199
+ const target = parsed.target;
200
+ if (!["express", "fastapi"].includes(framework)) {
201
+ throw new Error("init requires framework: express or fastapi.");
202
+ }
203
+ if (!target) {
204
+ throw new Error("init requires --target <directory>.");
205
+ }
206
+ const from = join(rootDir, "templates", framework);
207
+ const to = resolve(process.cwd(), target);
208
+ if (!Boolean(parsed.force)) {
209
+ const conflicts = await findCopyConflicts(from, to);
210
+ if (conflicts.length) {
211
+ throw new Error(`Refusing to overwrite existing files. Re-run with --force to overwrite:\n${conflicts.join("\n")}`);
212
+ }
213
+ }
214
+ await copyDir(from, to, Boolean(parsed.force));
215
+ console.log(`Copied ${framework} SDRP integration files to ${to}`);
216
+ console.log("Wire the exported router into your app, then run siglume-check readiness before opening checkout.");
217
+ }
218
+
219
+ async function findCopyConflicts(from, to) {
220
+ const conflicts = [];
221
+ for (const entry of await readdir(from)) {
222
+ const src = join(from, entry);
223
+ const dst = join(to, entry);
224
+ const info = await stat(src);
225
+ if (info.isDirectory()) {
226
+ conflicts.push(...await findCopyConflicts(src, dst));
227
+ } else if (await exists(dst)) {
228
+ conflicts.push(dst);
229
+ }
230
+ }
231
+ return conflicts;
232
+ }
233
+
234
+ async function copyDir(from, to, force) {
235
+ await mkdir(to, { recursive: true });
236
+ for (const entry of await readdir(from)) {
237
+ const src = join(from, entry);
238
+ const dst = join(to, entry);
239
+ const info = await stat(src);
240
+ if (info.isDirectory()) {
241
+ await copyDir(src, dst, force);
242
+ } else {
243
+ if (!force && await exists(dst)) {
244
+ throw new Error(`${dst} already exists. Re-run with --force to overwrite.`);
245
+ }
246
+ await mkdir(dirname(dst), { recursive: true });
247
+ await writeFile(dst, await readFile(src));
248
+ }
249
+ }
250
+ }
251
+
252
+ async function exists(path) {
253
+ try {
254
+ await stat(path);
255
+ return true;
256
+ } catch {
257
+ return false;
258
+ }
259
+ }
260
+
261
+ function loadDotEnv() {
262
+ try {
263
+ const text = readFileSync(resolve(process.cwd(), ".env"), "utf8");
264
+ for (const line of text.split(/\r?\n/)) {
265
+ const trimmed = line.trim();
266
+ if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) continue;
267
+ const [key, ...rest] = trimmed.split("=");
268
+ if (key && !process.env[key]) {
269
+ process.env[key] = rest.join("=").replace(/^["']|["']$/g, "");
270
+ }
271
+ }
272
+ } catch {
273
+ // .env is optional.
274
+ }
275
+ }
276
+
277
+ function check(checks, name, passed, message) {
278
+ checks.push({ name, status: passed ? "pass" : "fail", message: passed ? "ready" : message });
279
+ }
280
+
281
+ function warnIf(checks, name, condition, message) {
282
+ if (condition) {
283
+ checks.push({ name, status: "warn", message });
284
+ }
285
+ }
286
+
287
+ function hasFailures(checks) {
288
+ return checks.some((item) => item.status === "fail");
289
+ }
290
+
291
+ function isHttpsOrigin(value) {
292
+ try {
293
+ const url = new URL(value);
294
+ return url.protocol === "https:" && url.origin === value.replace(/\/$/, "");
295
+ } catch {
296
+ return false;
297
+ }
298
+ }
299
+
300
+ function isHttpsUrl(value) {
301
+ try {
302
+ const url = new URL(value);
303
+ return url.protocol === "https:";
304
+ } catch {
305
+ return false;
306
+ }
307
+ }
308
+
309
+ function normalizeCurrency(value) {
310
+ const currency = String(value || "").toUpperCase();
311
+ if (currency !== "JPY" && currency !== "USD") {
312
+ throw new Error("--currency must be JPY or USD.");
313
+ }
314
+ return currency;
315
+ }
316
+
317
+ function isStandardAmount(currency, amountMinor) {
318
+ return Number.isSafeInteger(amountMinor) && amountMinor >= (currency === "USD" ? 301 : 501);
319
+ }
320
+
321
+ function activeLike(value) {
322
+ return /^(active|ready|current|ok|enabled|paid|complete|completed)$/i.test(String(value || ""));
323
+ }
324
+
325
+ function includesEventType(eventTypes, eventType) {
326
+ if (!Array.isArray(eventTypes) || eventTypes.length === 0) return true;
327
+ return eventTypes.map((item) => String(item)).includes(eventType);
328
+ }
329
+
330
+ function urlsEqual(left, right) {
331
+ try {
332
+ const leftUrl = new URL(String(left || ""));
333
+ const rightUrl = new URL(String(right || ""));
334
+ return leftUrl.href === rightUrl.href;
335
+ } catch {
336
+ return false;
337
+ }
338
+ }
339
+
340
+ function subscriptionId(subscription) {
341
+ return String(subscription?.id || subscription?.webhook_subscription_id || subscription?.subscription_id || "");
342
+ }
343
+
344
+ async function checkWebhookDeliveryProbe(checks, merchantClient, { merchant, subscription }) {
345
+ const id = subscriptionId(subscription);
346
+ if (!id) {
347
+ check(checks, "webhook_delivery_probe_passed", false, "Cannot run webhook delivery probe without a matching subscription id.");
348
+ return;
349
+ }
350
+ try {
351
+ const queued = await merchantClient.queueWebhookTestDelivery({
352
+ event_type: "direct_payment.confirmed",
353
+ subscription_ids: [id],
354
+ data: {
355
+ mode: "readiness_probe",
356
+ merchant,
357
+ direct_payment_requirement_id: `dpr_readiness_${Date.now()}`,
358
+ requirement_id: `dpr_readiness_${Date.now()}`,
359
+ challenge_hash: "sha256:readiness_probe",
360
+ pricing_band: "standard",
361
+ settlement_status: "readiness_probe",
362
+ },
363
+ });
364
+ const eventId = String(queued?.event?.id || "");
365
+ const deadline = Date.now() + 10000;
366
+ while (eventId && Date.now() < deadline) {
367
+ const deliveries = await merchantClient.listWebhookDeliveries({
368
+ subscription_id: id,
369
+ event_type: "direct_payment.confirmed",
370
+ limit: 10,
371
+ });
372
+ const delivery = deliveries.find((item) => String(item.event_id || "") === eventId);
373
+ if (delivery?.delivery_status === "delivered") {
374
+ check(checks, "webhook_delivery_probe_passed", true, "ready");
375
+ return;
376
+ }
377
+ if (delivery?.delivery_status === "failed") {
378
+ check(checks, "webhook_delivery_probe_passed", false, `Webhook delivery failed with response_status=${delivery.response_status ?? "unknown"}.`);
379
+ return;
380
+ }
381
+ await sleep(1000);
382
+ }
383
+ check(checks, "webhook_delivery_probe_passed", false, "Webhook test delivery was queued but did not report delivered before timeout. Check callback reachability and delivery logs.");
384
+ } catch (error) {
385
+ check(checks, "webhook_delivery_probe_passed", false, apiErrorMessage(error, "Webhook delivery probe failed."));
386
+ }
387
+ }
388
+
389
+ function sleep(ms) {
390
+ return new Promise((resolve) => setTimeout(resolve, ms));
391
+ }
392
+
393
+ function apiErrorMessage(error, fallback) {
394
+ if (error instanceof SiglumeApiError) {
395
+ return `${fallback} ${error.code} (${error.status}).`;
396
+ }
397
+ return fallback;
398
+ }
package/dist/index.cjs CHANGED
@@ -83,7 +83,7 @@ var DIRECT_REQUEST_PAYMENT_RECEIPT_KIND = "sdrp_direct_payment";
83
83
  var DIRECT_REQUEST_PAYMENT_ALLOWANCE_RECEIPT_KIND = "sdrp_direct_payment_allowance";
84
84
  var DIRECT_REQUEST_PAYMENT_REFERENCE_TYPE = "sdrp_direct_payment_requirement";
85
85
  var DEFAULT_WEBHOOK_TOLERANCE_SECONDS = 300;
86
- var DIRECT_REQUEST_PAYMENT_SDK_VERSION = "0.4.18";
86
+ var DIRECT_REQUEST_PAYMENT_SDK_VERSION = "0.4.20";
87
87
  var DIRECT_REQUEST_PAYMENT_STANDARD_SETTLED_STATUS = "settled";
88
88
  var DIRECT_REQUEST_PAYMENT_METERED_ACCEPTED_STATUS = "pending_settlement";
89
89
  var DIRECT_REQUEST_PAYMENT_STANDARD_FINALITY = "per_payment_onchain";
@@ -404,6 +404,30 @@ var DirectRequestPaymentMerchantClient = class {
404
404
  }
405
405
  return this.request("POST", "/market/webhooks/subscriptions", payload);
406
406
  }
407
+ async listWebhookSubscriptions() {
408
+ return this.request("GET", "/market/webhooks/subscriptions");
409
+ }
410
+ async queueWebhookTestDelivery(input) {
411
+ const payload = {
412
+ event_type: requireNonEmpty(input.event_type, "event_type")
413
+ };
414
+ if (input.data !== void 0) {
415
+ payload.data = cloneJsonObject(input.data, "data");
416
+ }
417
+ if (input.subscription_ids !== void 0) {
418
+ payload.subscription_ids = input.subscription_ids.map((subscriptionId) => requireNonEmpty(subscriptionId, "subscription_id"));
419
+ }
420
+ return this.request("POST", "/market/webhooks/test-deliveries", payload);
421
+ }
422
+ async listWebhookDeliveries(input = {}) {
423
+ const params = new URLSearchParams();
424
+ if (input.subscription_id !== void 0) params.set("subscription_id", requireNonEmpty(input.subscription_id, "subscription_id"));
425
+ if (input.event_type !== void 0) params.set("event_type", requireNonEmpty(input.event_type, "event_type"));
426
+ if (input.status !== void 0) params.set("status", requireNonEmpty(input.status, "status"));
427
+ if (input.limit !== void 0) params.set("limit", String(positiveInteger(input.limit, "limit")));
428
+ const query = params.toString();
429
+ return this.request("GET", `/market/webhooks/deliveries${query ? `?${query}` : ""}`);
430
+ }
407
431
  async setupCheckout(input) {
408
432
  const merchant = await this.setupMerchant(input);
409
433
  const merchantKey = merchant.merchant_account.merchant;