@siglume/direct-request-payment 0.4.19 → 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,30 @@
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
+
3
28
  ## 0.4.19 - 2026-06-20
4
29
 
5
30
  Make the 10-minute integration path a real product-integration path instead of
package/README.md CHANGED
@@ -98,7 +98,8 @@ siglume-sdrp init fastapi --target app/siglume
98
98
  ```
99
99
 
100
100
  The readiness command checks account, billing, origin, webhook, and Hosted
101
- Checkout availability before you write checkout code.
101
+ Checkout availability before you write checkout code. It also confirms the
102
+ webhook subscription and signed test delivery when API probes are enabled.
102
103
 
103
104
  Before implementation, confirm Hosted Checkout readiness in
104
105
  [Troubleshooting](./docs/troubleshooting.md#hosted-checkout-readiness). For
@@ -119,8 +120,8 @@ fulfilling orders.
119
120
 
120
121
  | Use case | Recommended path | 10-minute integration path? | Production work still required |
121
122
  | --- | --- | --- | --- |
122
- | EC one-time Standard payment | Hosted Checkout | Yes, with `siglume-check readiness` and `siglume-sdrp init` | Refund/support process and monitoring |
123
- | 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 |
124
125
  | Paid API / AtoA | Direct API or Siglume marketplace tool | Conditional | Request idempotency, buyer auth context, reconciliation |
125
126
  | SaaS subscription | Recurring challenge plus raw API | No | Renewal, cancellation, failed renewal, plan-change lifecycle |
126
127
  | Scheduled autopay | Recurring challenge plus schedule token | No | Scheduler, token custody, budget failure handling |
@@ -171,8 +172,8 @@ redirect(session.checkout_url); // -> https://siglume.com/pay/<session_id>
171
172
 
172
173
  // 3. Handle the signed direct_payment.confirmed webhook. Use
173
174
  // classifyDirectPaymentConfirmation(event). Fulfill Standard only for
174
- // standard_settled; treat metered_usage_accepted as fulfilled-unsettled
175
- // 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.
176
177
  // Poll merchant.getCheckoutSession(session.session_id) if you also want to
177
178
  // show status in your own UI.
178
179
  ```
@@ -206,8 +207,8 @@ redirect(session["checkout_url"]) # -> https://siglume.com/pay/<session_id>
206
207
 
207
208
  # 3. Handle the signed direct_payment.confirmed webhook. Use
208
209
  # classify_direct_payment_confirmation(event). Fulfill Standard only for
209
- # standard_settled; treat metered_usage_accepted as fulfilled-unsettled
210
- # 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.
211
212
  # Poll merchant.get_checkout_session(session["session_id"]) if you also want
212
213
  # to show status in your own UI.
213
214
  ```
@@ -631,7 +632,8 @@ if (event.type === "direct_payment.confirmed") {
631
632
  } else if (confirmation.kind === "standard_settled") {
632
633
  // Mark the order paid once if event.data.challenge_hash/order mapping matches.
633
634
  } else if (confirmation.kind === "metered_usage_accepted") {
634
- // 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.
635
637
  } else {
636
638
  // Route confirmation.reason to manual review. Do not mark paid or fulfilled.
637
639
  }
@@ -662,7 +664,8 @@ if verified["event"]["type"] == "direct_payment.confirmed":
662
664
  # Mark the order paid once if event.data.challenge_hash/order mapping matches.
663
665
  pass
664
666
  elif confirmation["kind"] == "metered_usage_accepted":
665
- # 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.
666
669
  pass
667
670
  else:
668
671
  # Route confirmation["reason"] to manual review. Do not mark paid or fulfilled.
@@ -52,7 +52,7 @@ Readiness options:
52
52
  --amount-minor <amount> Standard-band probe amount. Defaults to 501 for JPY, 301 for USD.
53
53
  --base-url <url> Siglume API base URL. Defaults to SIGLUME_API_BASE or production.
54
54
  --no-api Validate local config only; do not call Siglume.
55
- --no-probe Call getMerchant only; do not create an unpaid checkout session.
55
+ --no-probe Partial API check only; readiness will not be reported as ready.
56
56
  --json Print machine-readable JSON.
57
57
  `);
58
58
  }
@@ -89,6 +89,7 @@ async function readiness(options) {
89
89
  const merchant = options.merchant || process.env.SIGLUME_DIRECT_PAYMENT_MERCHANT || "";
90
90
  const origin = options.origin || process.env.SHOP_PUBLIC_ORIGIN || "";
91
91
  const webhookUrl = options.webhookUrl || process.env.SHOP_WEBHOOK_URL || "";
92
+ const webhookSecret = process.env.SIGLUME_WEBHOOK_SECRET || "";
92
93
  const token = process.env.SIGLUME_MERCHANT_AUTH_TOKEN || process.env.SIGLUME_AUTH_TOKEN || "";
93
94
  const currency = normalizeCurrency(options.currency || process.env.SIGLUME_DIRECT_PAYMENT_TEST_CURRENCY || "JPY");
94
95
  const amountMinor = Number(options.amountMinor || process.env.SIGLUME_DIRECT_PAYMENT_TEST_AMOUNT_MINOR || (currency === "USD" ? 301 : 501));
@@ -97,6 +98,7 @@ async function readiness(options) {
97
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.");
98
99
  check(checks, "shop_origin", isHttpsOrigin(origin), "Set SHOP_PUBLIC_ORIGIN to an https origin, for example https://www.example.com.");
99
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.");
100
102
  check(checks, "standard_probe_amount", isStandardAmount(currency, amountMinor), "Use a Standard-band probe amount: JPY 501+ or USD 301+ minor units.");
101
103
 
102
104
  if (options.api && !hasFailures(checks)) {
@@ -104,17 +106,46 @@ async function readiness(options) {
104
106
  auth_token: token,
105
107
  base_url: options.baseUrl || process.env.SIGLUME_API_BASE,
106
108
  });
109
+ let matchingWebhookSubscription = null;
107
110
  try {
108
111
  const merchantResponse = await merchantClient.getMerchant(merchant);
109
112
  const account = merchantResponse.merchant_account || {};
110
113
  check(checks, "merchant_exists", Boolean(account.merchant), "Run merchant setup before checkout.");
111
- check(checks, "billing_mandate", Boolean(account.billing_mandate_id) || activeLike(account.billing_status), "Complete the merchant billing mandate wallet approval.");
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.`);
112
116
  warnIf(checks, "merchant_status", account.status && !activeLike(account.status), `Merchant status is ${account.status}; confirm it is allowed to accept payments.`);
113
- warnIf(checks, "billing_status", account.billing_status && !activeLike(account.billing_status), `Billing status is ${account.billing_status}; confirm it is active before accepting payments.`);
114
117
  } catch (error) {
115
118
  check(checks, "merchant_api", false, apiErrorMessage(error, "Could not read the merchant account."));
116
119
  }
117
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
+
118
149
  if (options.probe && !hasFailures(checks)) {
119
150
  try {
120
151
  const session = await merchantClient.createCheckoutSession({
@@ -134,6 +165,13 @@ async function readiness(options) {
134
165
  check(checks, "hosted_checkout", false, message);
135
166
  }
136
167
  }
168
+
169
+ if (options.probe && !hasFailures(checks)) {
170
+ await checkWebhookDeliveryProbe(checks, merchantClient, {
171
+ merchant,
172
+ subscription: matchingWebhookSubscription,
173
+ });
174
+ }
137
175
  }
138
176
 
139
177
  const ok = !hasFailures(checks);
@@ -144,7 +182,11 @@ async function readiness(options) {
144
182
  const mark = item.status === "pass" ? "OK" : item.status === "warn" ? "WARN" : "FAIL";
145
183
  console.log(`${mark} ${item.name}: ${item.message}`);
146
184
  }
147
- console.log(ok ? "Ready for 10-minute SDRP integration." : "Not ready. Fix the FAIL items before coding checkout.");
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
+ }
148
190
  }
149
191
  if (!ok) {
150
192
  process.exitCode = 1;
@@ -163,11 +205,32 @@ async function init(args) {
163
205
  }
164
206
  const from = join(rootDir, "templates", framework);
165
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
+ }
166
214
  await copyDir(from, to, Boolean(parsed.force));
167
215
  console.log(`Copied ${framework} SDRP integration files to ${to}`);
168
216
  console.log("Wire the exported router into your app, then run siglume-check readiness before opening checkout.");
169
217
  }
170
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
+
171
234
  async function copyDir(from, to, force) {
172
235
  await mkdir(to, { recursive: true });
173
236
  for (const entry of await readdir(from)) {
@@ -259,6 +322,74 @@ function activeLike(value) {
259
322
  return /^(active|ready|current|ok|enabled|paid|complete|completed)$/i.test(String(value || ""));
260
323
  }
261
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
+
262
393
  function apiErrorMessage(error, fallback) {
263
394
  if (error instanceof SiglumeApiError) {
264
395
  return `${fallback} ${error.code} (${error.status}).`;
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.19";
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;