@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.
@@ -356,7 +356,8 @@ Returns:
356
356
  `merchant.merchant_account.metadata_jsonb.metered_risk_acceptance` records the
357
357
  merchant's Micro / Nano delayed-settlement risk acceptance receipt with
358
358
  `terms_version`, `accepted_at`, `principal_user_id`, `receipt_id`, and fixed
359
- market thresholds `JPY: 10000` / `USD: 10000`.
359
+ market thresholds JPY 10,000 / USD 100.00 (`settlement_threshold_minor` is
360
+ `10000` for both JPY minor units and USD cents).
360
361
 
361
362
  Secrets are returned only when created or rotated. Existing secrets are not
362
363
  replayed by `getMerchant` / `get_merchant`.
@@ -372,7 +373,9 @@ POST /v1/sdrp/direct-payments/merchants
372
373
  Creates or updates the merchant account for the authenticated merchant user.
373
374
  Accepts the optional `checkout_allowed_origins: string[]` return-URL origin
374
375
  allowlist described under `setupCheckout` above; the same normalization and
375
- webhook-origin auto-allow apply.
376
+ webhook-origin auto-allow apply. Python annotates this direct response as
377
+ `DirectRequestPaymentMerchantResponse`; `setup_checkout(...)` returns
378
+ `DirectRequestPaymentCheckoutSetupResult`.
376
379
 
377
380
  ### `createCheckoutSession(input)` / `create_checkout_session(...)`
378
381
 
@@ -535,6 +538,41 @@ Defaults event types to `direct_payment.confirmed` and
535
538
  `direct_payment.spent`. The returned `signing_secret` is shown only at creation
536
539
  or rotation.
537
540
 
541
+ ### `listWebhookSubscriptions()` / `list_webhook_subscriptions()`
542
+
543
+ Calls:
544
+
545
+ ```text
546
+ GET /v1/market/webhooks/subscriptions
547
+ ```
548
+
549
+ Returns the current user's webhook subscriptions without the full signing
550
+ secret. Use `signing_secret_hint` to confirm that the local
551
+ `SIGLUME_WEBHOOK_SECRET` is the expected secret.
552
+
553
+ ### `queueWebhookTestDelivery(input)` / `queue_webhook_test_delivery(...)`
554
+
555
+ Calls:
556
+
557
+ ```text
558
+ POST /v1/market/webhooks/test-deliveries
559
+ ```
560
+
561
+ Queues a signed test event to one or more subscription ids. `siglume-check
562
+ readiness` uses this for a harmless `direct_payment.confirmed` readiness probe.
563
+
564
+ ### `listWebhookDeliveries(input)` / `list_webhook_deliveries(...)`
565
+
566
+ Calls:
567
+
568
+ ```text
569
+ GET /v1/market/webhooks/deliveries
570
+ ```
571
+
572
+ Supports `subscription_id`, `event_type`, `status`, and `limit`. Readiness polls
573
+ this list after queueing the test delivery and only passes when the matching
574
+ delivery status becomes `delivered`.
575
+
538
576
  ## `DirectRequestPaymentClient`
539
577
 
540
578
  Thin wrapper around the current Siglume Direct Request Payment HTTP contract.
@@ -1012,10 +1050,10 @@ the `Siglume-Signature` header; use
1012
1050
  `verifyDirectRequestPaymentWebhook(...)` /
1013
1051
  `verify_direct_request_payment_webhook(...)` for signature verification and
1014
1052
  parsing together. Throws
1015
- `SiglumeWebhookPayloadError` on a malformed event, or when a
1016
- `direct_payment.confirmed` event does not carry a supported Direct Request
1017
- Payment mode (`external_402` or `metered_settlement_batch`). The `payload`
1018
- argument is positional in both languages.
1053
+ `SiglumeWebhookPayloadError` on a malformed event. It does not reject an
1054
+ unsupported `direct_payment.confirmed` mode by itself; the classifier returns
1055
+ `kind: "unknown"` with `reason: "unsupported_confirmation_mode"` for that case.
1056
+ The `payload` argument is positional in both languages.
1019
1057
 
1020
1058
  For `direct_payment.confirmed`, inspect `event.data.pricing_band`,
1021
1059
  `event.data.settlement_cadence`, `event.data.finality`,
@@ -1039,13 +1077,15 @@ Recommended branch: call `classifyDirectPaymentConfirmation(event)` /
1039
1077
  pricing, per-payment on-chain finality, settled status, non-empty
1040
1078
  `requirement_id`, non-empty `challenge_hash`, and non-empty
1041
1079
  `chain_receipt_id`.
1042
- - `metered_usage_accepted`: treat the usage as accepted but unsettled. This
1043
- requires Micro / Nano pricing, `finality === "aggregated_onchain_settlement"`,
1044
- the matching settlement cadence (`micro` -> `weekly`, `nano` -> `monthly`),
1080
+ - `metered_usage_accepted`: treat the usage as accepted but unsettled only if
1081
+ your integration has explicitly enabled Micro / Nano delayed-settlement
1082
+ handling. This requires Micro / Nano pricing,
1083
+ `finality === "aggregated_onchain_settlement"`, the matching settlement
1084
+ cadence (`micro` -> `weekly`, `nano` -> `monthly`),
1045
1085
  `settlement_status === "pending_settlement"`, non-empty `requirement_id`, and
1046
- non-empty `challenge_hash`. SDRP merchant setup and terms assume the merchant
1047
- accepts this delayed aggregated settlement model for Micro / Nano amount
1048
- bands; reconcile final revenue from statement APIs / settlement batches.
1086
+ non-empty `challenge_hash`. Standard-only integrations should route this to
1087
+ review or return `METERED_INTEGRATION_REQUIRED`; Micro / Nano integrations
1088
+ must reconcile final revenue from statement APIs / settlement batches.
1049
1089
  - `unknown`: do not mark paid or fulfilled from the event type alone; fetch the
1050
1090
  requirement or route the event to manual review.
1051
1091
 
@@ -1153,7 +1193,11 @@ package exports `TypedDict` names for the high-risk response shapes:
1153
1193
  - `DirectRequestPaymentPastDueBlock`
1154
1194
  - `DirectRequestPaymentProviderMeteredTotals`
1155
1195
  - `DirectRequestPaymentListResponse`
1156
- - `DirectRequestPaymentMerchantSetupResponse`
1196
+ - `DirectRequestPaymentMerchantResponse`
1197
+ - `DirectRequestPaymentCheckoutSetupResult`
1198
+ - `DirectRequestPaymentMerchantSetupResponse` (compatibility alias for checkout setup result)
1199
+ - `DirectRequestPaymentWebhookSubscription`
1200
+ - `DirectRequestPaymentWebhookDelivery`
1157
1201
  - `DirectRequestPaymentWebhookVerification`
1158
1202
  - `DirectRequestPaymentConfirmationClassification`
1159
1203
 
@@ -459,16 +459,9 @@ if (confirmation.kind === "standard_settled") {
459
459
  }
460
460
 
461
461
  if (confirmation.kind === "metered_usage_accepted") {
462
- const order = await orders.findByChallengeHash(confirmation.challenge_hash);
463
- if (!order) {
464
- await orders.flagForPaymentStateReview({
465
- reason: "unknown_metered_challenge_hash",
466
- requirement_id: confirmation.requirement_id,
467
- });
468
- return new Response(null, { status: 204 });
469
- }
470
- await orders.markFulfilledButUnsettledOnce(order.id, {
471
- siglume_requirement_id: confirmation.requirement_id,
462
+ await orders.flagForPaymentStateReview({
463
+ reason: "metered_integration_required",
464
+ requirement_id: confirmation.requirement_id,
472
465
  pricing_band: confirmation.pricing_band,
473
466
  });
474
467
  return new Response(null, { status: 204 });
@@ -531,16 +524,9 @@ if confirmation["kind"] == "standard_settled":
531
524
  return "", 204
532
525
 
533
526
  if confirmation["kind"] == "metered_usage_accepted":
534
- order = orders.find_by_challenge_hash(confirmation["challenge_hash"])
535
- if not order:
536
- orders.flag_for_payment_state_review(
537
- reason="unknown_metered_challenge_hash",
538
- requirement_id=confirmation["requirement_id"],
539
- )
540
- return "", 204
541
- orders.mark_fulfilled_but_unsettled_once(
542
- order["id"],
543
- siglume_requirement_id=confirmation["requirement_id"],
527
+ orders.flag_for_payment_state_review(
528
+ reason="metered_integration_required",
529
+ requirement_id=confirmation["requirement_id"],
544
530
  pricing_band=confirmation["pricing_band"],
545
531
  )
546
532
  return "", 204
@@ -195,9 +195,12 @@ Threshold-control fields:
195
195
  - `settlement_trigger`: `amount_threshold` or `scheduled_close`
196
196
  - `settlement_threshold_minor`: JPY `10000` or USD `10000` minor units
197
197
  - `threshold_reached_at`: set when the fixed amount threshold closed the batch
198
- - `total_unsettled_exposure_minor`: open plus `notice_pending`, `ready`,
199
- `submitted`, retrying, and `past_due` provider gross exposure for the same
200
- buyer / provider / token / pricing band
198
+ - `total_unsettled_exposure_minor`: chargeable provider gross exposure for the
199
+ same buyer / provider / token / pricing band where the batch is not
200
+ `settled`, `uncollectible`, or `written_off`. This includes open usage,
201
+ `notice_pending`, `notice_delivery_failed`, `ready`, `submitted`,
202
+ `submitted_reconcile_required`, `failed_retryable`, `retrying`, and
203
+ `past_due`.
201
204
 
202
205
  JPY 10,000 and USD 100.00 are market-specific fixed thresholds, not FX
203
206
  conversions of one another.
@@ -318,7 +321,7 @@ Important batch fields:
318
321
  | `settlement_trigger` | `amount_threshold` for early threshold close, or `scheduled_close` for weekly/monthly close |
319
322
  | `settlement_threshold_minor` | Fixed market threshold for early settlement: JPY `10000` or USD `10000` minor units |
320
323
  | `threshold_reached_at` | Timestamp when the fixed threshold closed the batch, otherwise null |
321
- | `total_unsettled_exposure_minor` | Current open plus notice/ready/submitted/retrying/past-due provider gross exposure for the same buyer / provider / token / pricing band |
324
+ | `total_unsettled_exposure_minor` | Chargeable provider gross exposure for the same buyer / provider / token / pricing band where status is not `settled`, `uncollectible`, or `written_off`; includes open, notice, ready, submitted, reconcile-required, retryable, retrying, and past-due states |
322
325
  | `expected_scheduled_debit_at` | Expected debit time for an open period before a batch exists |
323
326
  | `scheduled_debit_at` | Scheduled debit time after batch creation |
324
327
  | `not_before_attempt_at` | Earliest allowed debit attempt; this is the close-plus-3-day gate |
@@ -398,10 +401,10 @@ Siglume retries failed Micro / Nano settlement every 6 hours for up to 28
398
401
  automatic attempts. After that the batch remains `past_due` until operator
399
402
  requeue.
400
403
 
401
- New Micro / Nano usage for the same buyer / provider / token is paused while
402
- the total unsettled exposure is at or above the fixed threshold, and while a
403
- failed or past-due block remains. The provider API is not called for the
404
- rejected request, and the request is not charged.
404
+ New Micro / Nano usage for the same buyer / provider / token / pricing band is
405
+ paused while the total unsettled exposure is at or above the fixed threshold,
406
+ and while a failed or past-due block remains. The provider API is not called for
407
+ the rejected request, and the request is not charged.
405
408
 
406
409
  Public failure fields are sanitized. Show `failure_reason_code`,
407
410
  `failure_reason_label`, `failure_reason_help`, and `support_reference` to users
@@ -449,13 +452,12 @@ using stable idempotency keys and provider-side completion records.
449
452
  | --- | --- | --- | --- | --- |
450
453
  | `notice_delivery_failed` | Buyer debit is not yet allowed; provider revenue remains unsettled | Notice delivery can be retried or reviewed | Required if delivery keeps failing | Do not attempt your own debit notice or mark revenue settled. Show support context only. |
451
454
  | `submitted_reconcile_required` | A settlement submission exists but final on-chain outcome is not yet reconciled | Reconciliation may complete if a receipt is found | Required if reconciliation stalls | Do not retry payment yourself. Wait for `settled`, `failed_retryable`, or `past_due`. |
452
- | `past_due` | Buyer has an unresolved settlement block; provider sees past-due revenue | New Micro / Nano usage for the same buyer / plan / token is paused | Operator requeue or manual resolution only | Do not promise collection or provider payment. Ask the buyer to repair balance / allowance / BudgetVault / caps and reference `support_reference`. |
455
+ | `past_due` | Buyer has an unresolved settlement block; provider sees past-due revenue | New Micro / Nano usage for the same buyer / provider / token / pricing band is paused | Operator requeue or manual resolution only | Do not promise collection or provider payment. Ask the buyer to repair balance / allowance / BudgetVault / caps and reference `support_reference`. |
453
456
  | `failed_chargeable` | Usage is still chargeable because provider work was accepted or completed | Included in later settlement attempts | Review if the provider disputes completion | Keep fulfillment idempotent and preserve evidence keyed by idempotency key. |
454
457
 
455
- Future platform versions may add explicit terminal states such as
456
- `closed_unpaid`, `uncollectible`, or `written_off`. Treat unknown terminal
457
- settlement states as not settled unless `status === "settled"` and
458
- `chain_receipt_id` is present.
458
+ Terminal public states include `uncollectible` and `written_off` after operator
459
+ review. Treat unknown terminal settlement states as not settled unless
460
+ `status === "settled"` and `chain_receipt_id` is present.
459
461
 
460
462
  ## Operational Recipes
461
463
 
@@ -32,7 +32,7 @@ checkout open or agent/API payment starts
32
32
  -> usage accepted
33
33
  -> direct_payment.confirmed webhook
34
34
  -> classifier kind: metered_usage_accepted
35
- -> merchant may fulfill as fulfilled_unsettled
35
+ -> merchant may fulfill as fulfilled_unsettled only after enabling Micro/Nano handling
36
36
  -> open period closes by amount threshold or schedule
37
37
  -> final notice window
38
38
  -> submitted / retrying / past_due if needed
@@ -41,10 +41,12 @@ checkout open or agent/API payment starts
41
41
  -> provider revenue is settled
42
42
  ```
43
43
 
44
- For Micro / Nano, `metered_usage_accepted` means the usage can be fulfilled
45
- under the SDRP delayed settlement model, but provider revenue is not settled
46
- yet. Provider revenue becomes settled only when the settlement batch is settled
47
- on-chain and has a `chain_receipt_id`.
44
+ For Micro / Nano, `metered_usage_accepted` means the usage can be fulfilled only
45
+ by an integration that has explicitly accepted SDRP delayed settlement and
46
+ implemented fulfilled-but-unsettled state, settlement reconciliation, past-due
47
+ handling, and terminal accounting. Provider revenue is not settled yet. Provider
48
+ revenue becomes settled only when the settlement batch is settled on-chain and
49
+ has a `chain_receipt_id`.
48
50
 
49
51
  ## Field meanings
50
52
 
@@ -52,7 +54,7 @@ on-chain and has a `chain_receipt_id`.
52
54
  | --- | --- | --- |
53
55
  | Hosted Checkout `status: "paid"` | The checkout session accepted the wallet payment flow. | For Micro / Nano, it does not mean provider revenue is settled. |
54
56
  | `standard_settled` | Standard payment is on-chain settled and can mark an order paid. | It is not used for Micro / Nano accepted usage. |
55
- | `metered_usage_accepted` | Micro / Nano usage is accepted and may be fulfilled as unsettled. | It is not settled provider revenue. |
57
+ | `metered_usage_accepted` | Micro / Nano usage is accepted for integrations that have enabled delayed settlement handling. | It is not settled provider revenue, and Standard-only integrations should not fulfill it. |
56
58
  | `fulfilled_unsettled` | Your merchant system delivered the item before Micro / Nano settlement. | It is not a Siglume settlement status. |
57
59
  | `metered_batch_settled` | Aggregated Micro / Nano batch settled on-chain. | It does not identify one order by challenge hash. |
58
60
  | `pending_settlement` | Micro / Nano usage is waiting for aggregated settlement. | It is not a failure by itself. |
@@ -65,9 +67,10 @@ on-chain and has a `chain_receipt_id`.
65
67
  re-stringified JSON object.
66
68
  - Store `challenge_hash` on the order before redirecting the buyer.
67
69
  - For Standard, mark paid only from `standard_settled`.
68
- - For Micro / Nano, use a separate local state such as
69
- `fulfilled_unsettled`; reconcile final revenue from statement APIs and batch
70
- settlement events.
70
+ - For Micro / Nano, fulfill only after you have explicitly enabled delayed
71
+ settlement handling. Use a separate local state such as
72
+ `fulfilled_unsettled`, then reconcile final revenue from statement APIs and
73
+ batch settlement events.
71
74
  - Treat `unknown` classifications as manual review. Do not mark paid or
72
75
  fulfilled from the event name alone.
73
76
 
package/docs/pricing.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Pricing
2
2
 
3
3
  This page documents the trial-phase merchant pricing for Siglume Direct Request
4
- Payment as of SDK v0.4.19. Pricing can change by agreement or future product
4
+ Payment as of SDK v0.4.20. Pricing can change by agreement or future product
5
5
  release; the Siglume platform response is the source of truth for per-payment
6
6
  fee data returned at runtime.
7
7
 
@@ -156,9 +156,12 @@ confirmed payment turns into money in your settlement wallet.
156
156
  - While the same buyer / provider / token / pricing band has total unsettled
157
157
  exposure at or above the fixed threshold, new Micro/Nano usage is paused with the
158
158
  machine-readable error `METERED_SETTLEMENT_PAST_DUE`; the provider API is not
159
- called. Exposure includes open usage plus `notice_pending`, `ready`,
160
- `submitted`, retrying, and `past_due` batches, and remains paused while
161
- settlement failure or `past_due` is unresolved.
159
+ called. Exposure is chargeable provider gross where status is not `settled`,
160
+ `uncollectible`, or `written_off`; it includes open usage,
161
+ `notice_pending`, `notice_delivery_failed`, `ready`, `submitted`,
162
+ `submitted_reconcile_required`, `failed_retryable`, `retrying`, and
163
+ `past_due`. Usage remains paused while settlement failure or `past_due` is
164
+ unresolved.
162
165
  - Outstanding amounts remain attached to the failed settlement and are retried
163
166
  under this policy. They are not settled revenue, and Siglume does not advance,
164
167
  guarantee, or insure provider revenue before on-chain settlement succeeds.
@@ -34,27 +34,35 @@ SIGLUME_MERCHANT_AUTH_TOKEN=<merchant Siglume bearer token>
34
34
  SIGLUME_DIRECT_PAYMENT_MERCHANT=<merchant key>
35
35
  SHOP_PUBLIC_ORIGIN=https://www.your-product.example
36
36
  SHOP_WEBHOOK_URL=https://api.your-product.example/payments/webhooks/siglume
37
+ SIGLUME_WEBHOOK_SECRET=<webhook signing secret from setupCheckout/setup_checkout>
37
38
  ```
38
39
 
39
- Then run:
40
+ Then run the matching CLI:
40
41
 
41
42
  ```bash
43
+ # Node / Express
42
44
  npx siglume-check readiness
45
+
46
+ # Python / FastAPI
47
+ siglume-check readiness
43
48
  ```
44
49
 
45
50
  The readiness check fails before you write checkout code if any required item is
46
- missing. It checks local config, reads the merchant account, and creates one
47
- unpaid expiring Hosted Checkout probe session to prove the account is enabled
48
- and the return origin is accepted. No buyer is charged.
51
+ missing. It checks local config, reads the merchant account, requires active
52
+ billing, confirms the webhook subscription points to `SHOP_WEBHOOK_URL`, checks
53
+ that `direct_payment.confirmed` is subscribed, verifies the local webhook secret
54
+ against the subscription hint, creates one unpaid expiring Hosted Checkout probe
55
+ session, and queues a signed webhook test delivery. No buyer is charged.
49
56
 
50
- For CI or a preflight script:
57
+ For a CI local-config smoke test:
51
58
 
52
59
  ```bash
53
- npx siglume-check readiness --json
60
+ npx siglume-check readiness --no-api --json
54
61
  ```
55
62
 
56
- If this command fails, fix the reported item first. Do not build a human web
57
- checkout path until readiness passes.
63
+ `--no-api` does not prove Hosted Checkout or webhook delivery. Before opening a
64
+ human web checkout path, run readiness without `--no-api` and fix every FAIL
65
+ item.
58
66
 
59
67
  ## 1. Copy integration files into your product
60
68
 
@@ -78,16 +86,31 @@ generated files are intentionally small and are meant to be edited.
78
86
  Express:
79
87
 
80
88
  ```ts
81
- import { createSiglumeSdrpRouter } from "./siglume/siglume-sdrp-routes.js";
89
+ import express from "express";
90
+ import {
91
+ createSiglumeSdrpCheckoutRouter,
92
+ createSiglumeSdrpWebhookHandler,
93
+ type SiglumeSdrpRouterOptions,
94
+ } from "./siglume/siglume-sdrp-routes.js";
82
95
  import { siglumeOrderStore } from "./siglume/siglume-order-store.example.js";
83
96
 
84
- app.use("/payments", createSiglumeSdrpRouter({
97
+ const siglumeOptions: SiglumeSdrpRouterOptions = {
85
98
  merchant: process.env.SIGLUME_DIRECT_PAYMENT_MERCHANT!,
86
99
  merchant_auth_token: process.env.SIGLUME_MERCHANT_AUTH_TOKEN!,
87
100
  webhook_secret: process.env.SIGLUME_WEBHOOK_SECRET!,
88
101
  shop_public_origin: process.env.SHOP_PUBLIC_ORIGIN!,
89
102
  order_store: siglumeOrderStore,
90
- }));
103
+ allow_metered_payments: false,
104
+ };
105
+
106
+ app.post(
107
+ "/payments/webhooks/siglume",
108
+ express.raw({ type: "application/json" }),
109
+ createSiglumeSdrpWebhookHandler(siglumeOptions),
110
+ );
111
+
112
+ app.use(express.json());
113
+ app.use("/payments", createSiglumeSdrpCheckoutRouter(siglumeOptions));
91
114
  ```
92
115
 
93
116
  FastAPI:
@@ -97,7 +120,7 @@ from .siglume.siglume_order_store_example import ExampleSiglumeOrderStore
97
120
  from .siglume.siglume_sdrp_routes import create_siglume_sdrp_router
98
121
 
99
122
  app.include_router(
100
- create_siglume_sdrp_router(ExampleSiglumeOrderStore()),
123
+ create_siglume_sdrp_router(ExampleSiglumeOrderStore(), allow_metered_payments=False),
101
124
  prefix="/payments",
102
125
  )
103
126
  ```
@@ -109,14 +132,20 @@ Replace the example store with your product's order database. The adapter must:
109
132
  - load the order by your `order_id`,
110
133
  - verify the current user is allowed to pay for that order,
111
134
  - return the server-authored `amount_minor` and `currency`,
112
- - persist `challenge_hash` and `checkout_session_id` before redirecting,
113
- - record webhook event ids durably,
135
+ - create or reuse one active checkout attempt with a stable nonce,
136
+ - persist `challenge_hash`, `checkout_session_id`, and `checkout_url` before redirecting,
137
+ - process webhook event ids durably in the same transaction as the order update,
114
138
  - mark Standard orders paid exactly once,
115
- - mark Micro / Nano orders as fulfilled but unsettled exactly once,
116
139
  - route unknown classifications to manual review.
117
140
 
118
141
  Do not calculate the amount from browser input.
119
142
 
143
+ The generated route defaults to Standard-only. If an order amount falls into
144
+ Micro / Nano, checkout returns `METERED_INTEGRATION_REQUIRED` until you set
145
+ `allow_metered_payments: true` / `allow_metered_payments=True` and implement
146
+ fulfilled-but-unsettled state, settlement reconciliation, past-due handling, and
147
+ terminal write-off handling.
148
+
120
149
  ## 4. Start checkout from your frontend
121
150
 
122
151
  Call your own server route:
@@ -135,10 +164,10 @@ Your product is integrated when:
135
164
 
136
165
  - `npx siglume-check readiness` passes,
137
166
  - your product has mounted checkout and webhook routes,
138
- - your order database stores `challenge_hash` for the order,
167
+ - your order database stores one active checkout attempt and `challenge_hash` for the order,
139
168
  - the signed webhook verifies against the raw body,
140
169
  - `standard_settled` marks the order paid once,
141
- - `metered_usage_accepted` uses a separate fulfilled-but-unsettled state.
170
+ - duplicate webhook deliveries do not double-fulfill the order.
142
171
 
143
172
  For Micro / Nano revenue reconciliation, read
144
173
  [Payment lifecycle](./payment-lifecycle.md) and
@@ -14,16 +14,22 @@ building a human web checkout:
14
14
  npx siglume-check readiness
15
15
  ```
16
16
 
17
- The command validates local configuration, reads the merchant account, and
18
- creates one unpaid expiring checkout session to prove Hosted Checkout is
19
- available for this merchant account.
17
+ The command validates local configuration, reads the merchant account, checks
18
+ the active billing mandate, confirms the webhook subscription, creates one
19
+ unpaid expiring checkout session, and queues a signed webhook test delivery.
20
20
 
21
21
  - The merchant account exists.
22
22
  - The merchant billing mandate is active.
23
- - The webhook callback URL is HTTPS and reachable.
23
+ - `SIGLUME_WEBHOOK_SECRET` is present and matches the subscription secret hint.
24
+ - The webhook callback URL is HTTPS and matches an active subscription.
25
+ - The subscription includes `direct_payment.confirmed`.
24
26
  - The checkout return URL origins are registered through
25
27
  `checkout_allowed_origins`.
26
28
  - The account has Hosted Checkout enabled.
29
+ - The signed webhook test delivery reaches the endpoint and returns success.
30
+
31
+ `--no-api` is only for local config smoke tests. `--no-probe` is a partial API
32
+ check and does not report readiness as ready.
27
33
 
28
34
  If `createCheckoutSession(...)` or `getCheckoutSession(...)` raises
29
35
  `HostedCheckoutNotAvailableError`, do not show the raw 404/409 to the buyer.
@@ -46,10 +52,11 @@ contact for Hosted Checkout enablement.
46
52
 
47
53
  - Verify the exact raw request body bytes or raw body string.
48
54
  - Do not verify a parsed JSON object or a re-stringified JSON body.
49
- - Return a 2xx only after you have durably recorded the event or safely decided
50
- it is duplicate/ignored.
51
- - Store processed webhook event ids or settlement identifiers durably; an
52
- in-memory set is not enough for production.
55
+ - Return a 2xx only after the order update or durable manual-review write has
56
+ succeeded, or after you safely decided the event is duplicate/ignored.
57
+ - Store processed webhook event ids or settlement identifiers durably, in the
58
+ same database transaction as the order update/review write. An in-memory set
59
+ is not enough for production.
53
60
  - Do not assume delivery order. A settlement batch event may be reconciled from
54
61
  statement APIs rather than from one order challenge.
55
62
  - On signature failure, return a non-2xx status and do not mutate order state.
@@ -36,11 +36,21 @@ app.use((req, res, next) => {
36
36
  });
37
37
 
38
38
  const orders = new Map<string, any>();
39
+ const processedWebhookEvents = new Set<string>();
39
40
 
40
41
  async function flagForPaymentStateReview(payload: Record<string, any>): Promise<void> {
41
42
  console.warn("payment state review required", payload);
42
43
  }
43
44
 
45
+ async function processWebhookEventOnce(eventId: string, handler: () => Promise<void>): Promise<"processed" | "duplicate"> {
46
+ if (processedWebhookEvents.has(eventId)) {
47
+ return "duplicate";
48
+ }
49
+ await handler();
50
+ processedWebhookEvents.add(eventId);
51
+ return "processed";
52
+ }
53
+
44
54
  async function handleDirectPaymentConfirmed(event: any): Promise<void> {
45
55
  const classification = classifyDirectPaymentConfirmation(event);
46
56
 
@@ -67,16 +77,11 @@ async function handleDirectPaymentConfirmed(event: any): Promise<void> {
67
77
  }
68
78
 
69
79
  if (classification.kind === "metered_usage_accepted") {
70
- const order = [...orders.values()].find((item) => item.siglume_challenge_hash === classification.challenge_hash);
71
- if (order) {
72
- order.siglume_payment_status = "fulfilled_unsettled";
73
- order.siglume_requirement_id = classification.requirement_id;
74
- } else {
75
- await flagForPaymentStateReview({
76
- reason: "unknown_metered_challenge_hash",
77
- requirement_id: classification.requirement_id,
78
- });
79
- }
80
+ await flagForPaymentStateReview({
81
+ reason: "metered_integration_required",
82
+ requirement_id: classification.requirement_id,
83
+ pricing_band: classification.pricing_band,
84
+ });
80
85
  return;
81
86
  }
82
87
 
@@ -102,7 +107,19 @@ app.post("/checkout/siglume/start", asyncRoute(async (req, res) => {
102
107
  return;
103
108
  }
104
109
 
105
- order.payment_attempt = Number(order.payment_attempt || 0) + 1;
110
+ if (!Number(order.payment_attempt || 0)) {
111
+ order.payment_attempt = 1;
112
+ }
113
+ if (order.siglume_checkout_url && order.siglume_checkout_session_id) {
114
+ res.json({
115
+ order_id: order.id,
116
+ amount_minor: order.amount_minor,
117
+ currency: order.currency,
118
+ checkout_url: order.siglume_checkout_url,
119
+ session_id: order.siglume_checkout_session_id,
120
+ });
121
+ return;
122
+ }
106
123
  const session = await siglumeMerchant.createCheckoutSession({
107
124
  merchant: merchantKey,
108
125
  amount_minor: order.amount_minor,
@@ -114,6 +131,7 @@ app.post("/checkout/siglume/start", asyncRoute(async (req, res) => {
114
131
  });
115
132
 
116
133
  order.siglume_challenge_hash = session.challenge_hash;
134
+ order.siglume_checkout_url = session.checkout_url;
117
135
  order.siglume_checkout_session_id = session.session_id;
118
136
  order.siglume_payment_status = "pending";
119
137
 
@@ -134,8 +152,14 @@ app.post("/siglume/webhook", express.raw({ type: "application/json" }), asyncRou
134
152
  header,
135
153
  );
136
154
 
137
- if (event.type === "direct_payment.confirmed") {
138
- await handleDirectPaymentConfirmed(event);
155
+ const result = await processWebhookEventOnce(event.id, async () => {
156
+ if (event.type === "direct_payment.confirmed") {
157
+ await handleDirectPaymentConfirmed(event);
158
+ }
159
+ });
160
+ if (result === "duplicate") {
161
+ res.status(204).send();
162
+ return;
139
163
  }
140
164
 
141
165
  res.status(204).send();