@siglume/direct-request-payment 0.1.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/LICENSE +21 -0
- package/README.md +237 -0
- package/dist/index.cjs +555 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +235 -0
- package/dist/index.d.ts +235 -0
- package/dist/index.js +524 -0
- package/dist/index.js.map +1 -0
- package/docs/api-reference.md +116 -0
- package/docs/merchant-quickstart.md +255 -0
- package/docs/pricing.md +56 -0
- package/docs/security.md +85 -0
- package/examples/express-checkout.ts +105 -0
- package/package.json +57 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# API Reference
|
|
2
|
+
|
|
3
|
+
The TypeScript package is `@siglume/direct-request-payment`. The Python package
|
|
4
|
+
is `siglume-direct-request-payment` and imports as
|
|
5
|
+
`siglume_direct_request_payment`.
|
|
6
|
+
|
|
7
|
+
## `createDirectRequestPaymentChallenge(input)` / `create_direct_request_payment_challenge(...)`
|
|
8
|
+
|
|
9
|
+
Creates the merchant-signed challenge required by Siglume.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
const challenge = await createDirectRequestPaymentChallenge({
|
|
13
|
+
merchant: "example_merchant",
|
|
14
|
+
amount_minor: 1200,
|
|
15
|
+
currency: "JPY",
|
|
16
|
+
secret: process.env.SIGLUME_DIRECT_PAYMENT_CHALLENGE_SECRET!,
|
|
17
|
+
nonce: "order_123-attempt_1",
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```py
|
|
22
|
+
import os
|
|
23
|
+
|
|
24
|
+
challenge = create_direct_request_payment_challenge(
|
|
25
|
+
merchant="example_merchant",
|
|
26
|
+
amount_minor=1200,
|
|
27
|
+
currency="JPY",
|
|
28
|
+
secret=os.environ["SIGLUME_DIRECT_PAYMENT_CHALLENGE_SECRET"],
|
|
29
|
+
nonce="order_123-attempt_1",
|
|
30
|
+
)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
|
|
35
|
+
- `challenge`: value to pass to Siglume
|
|
36
|
+
- `challenge_hash`: value to store on the order
|
|
37
|
+
- `signature`: HMAC-SHA256 hex digest
|
|
38
|
+
- `nonce`
|
|
39
|
+
|
|
40
|
+
`nonce` must not contain `:` because the platform challenge string is delimited
|
|
41
|
+
as `scheme:nonce:signature`.
|
|
42
|
+
|
|
43
|
+
## `verifyDirectRequestPaymentChallenge(secret, input)` / `verify_direct_request_payment_challenge(...)`
|
|
44
|
+
|
|
45
|
+
Verifies a challenge against merchant, amount, currency, and secret. This is
|
|
46
|
+
useful in tests and internal checkout assertions.
|
|
47
|
+
|
|
48
|
+
## `DirectRequestPaymentClient`
|
|
49
|
+
|
|
50
|
+
Thin wrapper around the current Siglume Direct Request Payment HTTP contract.
|
|
51
|
+
Use it with the authenticated buyer's Siglume bearer token. Developer Portal
|
|
52
|
+
`cli_` API keys are not accepted by these buyer-authenticated routes.
|
|
53
|
+
|
|
54
|
+
Payment requirements include `fee_bps` from the Siglume platform. The SDK does
|
|
55
|
+
not calculate merchant plan fees locally; see [Pricing](./pricing.md).
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
const siglume = new DirectRequestPaymentClient({
|
|
59
|
+
auth_token: buyerSiglumeBearerToken,
|
|
60
|
+
base_url: "https://siglume.com/v1",
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```py
|
|
65
|
+
siglume = DirectRequestPaymentClient(
|
|
66
|
+
auth_token=buyer_siglume_bearer_token,
|
|
67
|
+
base_url="https://siglume.com/v1",
|
|
68
|
+
)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### `createPaymentRequirement(input)` / `create_payment_requirement(...)`
|
|
72
|
+
|
|
73
|
+
Calls:
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
POST /v1/market/api-store/direct-payments/requirements
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The SDK sends `mode="external_402"` internally.
|
|
80
|
+
|
|
81
|
+
### `executeAllowanceTransaction(requirement)` / `execute_allowance_transaction(...)`
|
|
82
|
+
|
|
83
|
+
Executes `requirement.approve_transaction_request` through:
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
POST /v1/market/web3/transactions/execute-prepared
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Only call this when Siglume returned an approval transaction.
|
|
90
|
+
|
|
91
|
+
### `executePaymentTransaction(requirement)` / `execute_payment_transaction(...)`
|
|
92
|
+
|
|
93
|
+
Executes `requirement.transaction_request` through the same prepared transaction
|
|
94
|
+
route.
|
|
95
|
+
|
|
96
|
+
### `verifyPaymentRequirement(requirement_id, input)` / `verify_payment_requirement(...)`
|
|
97
|
+
|
|
98
|
+
Calls:
|
|
99
|
+
|
|
100
|
+
```text
|
|
101
|
+
POST /v1/market/api-store/direct-payments/requirements/{requirement_id}/verify
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Webhook Helpers
|
|
105
|
+
|
|
106
|
+
- `buildWebhookSignatureHeader(secret, body)` for tests
|
|
107
|
+
- `verifyWebhookSignature(secret, body, header)`
|
|
108
|
+
- `verifyDirectRequestPaymentWebhook(secret, body, header)`
|
|
109
|
+
- `parseDirectRequestPaymentWebhookEvent(payload)`
|
|
110
|
+
- Python equivalents use snake_case:
|
|
111
|
+
`build_webhook_signature_header`, `verify_webhook_signature`,
|
|
112
|
+
`verify_direct_request_payment_webhook`, and
|
|
113
|
+
`parse_direct_request_payment_webhook_event`.
|
|
114
|
+
|
|
115
|
+
`verifyDirectRequestPaymentWebhook` verifies the signature and parses the event
|
|
116
|
+
in one call.
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# Merchant Quickstart
|
|
2
|
+
|
|
3
|
+
This guide shows the minimum safe Siglume Direct Request Payment flow for an
|
|
4
|
+
external merchant.
|
|
5
|
+
|
|
6
|
+
## Actors
|
|
7
|
+
|
|
8
|
+
- Merchant server: owns the order, amount, currency, challenge secret, webhook
|
|
9
|
+
endpoint, and order fulfillment.
|
|
10
|
+
- Buyer: owns the Siglume wallet that pays the DirectPaymentHub transaction.
|
|
11
|
+
- Siglume: creates the payment requirement, prepares the wallet transaction,
|
|
12
|
+
verifies the receipt, and emits signed webhooks.
|
|
13
|
+
|
|
14
|
+
The merchant server must not create charges with a customer wallet. It signs the
|
|
15
|
+
order challenge; the buyer-facing Siglume payment flow pays it.
|
|
16
|
+
|
|
17
|
+
## 1. Configure the Merchant
|
|
18
|
+
|
|
19
|
+
During onboarding, Siglume assigns:
|
|
20
|
+
|
|
21
|
+
- `merchant` key, for example `example_merchant`
|
|
22
|
+
- challenge secret
|
|
23
|
+
- allowed currency and token, initially `JPY`/`JPYC`; `USD`/`USDC` requires
|
|
24
|
+
separately agreed merchant billing terms
|
|
25
|
+
- billing plan; see [Pricing](./pricing.md)
|
|
26
|
+
|
|
27
|
+
Keep the challenge secret server-side. Create a marketplace webhook subscription
|
|
28
|
+
to receive the `whsec_` signing secret.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
curl -X POST https://siglume.com/v1/market/webhooks/subscriptions \
|
|
32
|
+
-H "Authorization: Bearer <merchant-siglume-bearer-token>" \
|
|
33
|
+
-H "Content-Type: application/json" \
|
|
34
|
+
-d '{
|
|
35
|
+
"callback_url": "https://merchant.example/siglume/webhook",
|
|
36
|
+
"event_types": ["direct_payment.confirmed"],
|
|
37
|
+
"description": "Direct Request Payment production webhook"
|
|
38
|
+
}'
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The `signing_secret` is returned only when the subscription is created or
|
|
42
|
+
rotated. Store it as `SIGLUME_WEBHOOK_SECRET`.
|
|
43
|
+
|
|
44
|
+
## 2. Create an Order and Challenge
|
|
45
|
+
|
|
46
|
+
The merchant server creates the order before asking Siglume for payment.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { createDirectRequestPaymentChallenge } from "@siglume/direct-request-payment";
|
|
50
|
+
|
|
51
|
+
const order = {
|
|
52
|
+
id: "order_123",
|
|
53
|
+
amount_minor: 1200,
|
|
54
|
+
currency: "JPY",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const challenge = await createDirectRequestPaymentChallenge({
|
|
58
|
+
merchant: "example_merchant",
|
|
59
|
+
amount_minor: order.amount_minor,
|
|
60
|
+
currency: order.currency,
|
|
61
|
+
secret: process.env.SIGLUME_DIRECT_PAYMENT_CHALLENGE_SECRET!,
|
|
62
|
+
nonce: `${order.id}-attempt_1`,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await orders.update(order.id, {
|
|
66
|
+
siglume_challenge_hash: challenge.challenge_hash,
|
|
67
|
+
siglume_payment_status: "pending",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
order_id: order.id,
|
|
72
|
+
amount_minor: order.amount_minor,
|
|
73
|
+
currency: order.currency,
|
|
74
|
+
siglume_challenge: challenge.challenge,
|
|
75
|
+
};
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Python:
|
|
79
|
+
|
|
80
|
+
```py
|
|
81
|
+
import os
|
|
82
|
+
|
|
83
|
+
from siglume_direct_request_payment import create_direct_request_payment_challenge
|
|
84
|
+
|
|
85
|
+
order = {
|
|
86
|
+
"id": "order_123",
|
|
87
|
+
"amount_minor": 1200,
|
|
88
|
+
"currency": "JPY",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
challenge = create_direct_request_payment_challenge(
|
|
92
|
+
merchant="example_merchant",
|
|
93
|
+
amount_minor=order["amount_minor"],
|
|
94
|
+
currency=order["currency"],
|
|
95
|
+
secret=os.environ["SIGLUME_DIRECT_PAYMENT_CHALLENGE_SECRET"],
|
|
96
|
+
nonce=f"{order['id']}-attempt_1",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
orders.update(
|
|
100
|
+
order["id"],
|
|
101
|
+
{
|
|
102
|
+
"siglume_challenge_hash": challenge["challenge_hash"],
|
|
103
|
+
"siglume_payment_status": "pending",
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
"order_id": order["id"],
|
|
109
|
+
"amount_minor": order["amount_minor"],
|
|
110
|
+
"currency": order["currency"],
|
|
111
|
+
"siglume_challenge": challenge["challenge"],
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Never calculate `amount_minor` from browser input.
|
|
116
|
+
The nonce must be unique per order payment attempt and must not contain `:`.
|
|
117
|
+
|
|
118
|
+
## 3. Buyer Creates and Pays the Requirement
|
|
119
|
+
|
|
120
|
+
After the buyer authenticates with Siglume, create the payment requirement with
|
|
121
|
+
the buyer's Siglume bearer token. Do not use a Developer Portal `cli_` API key
|
|
122
|
+
or merchant API key here.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { DirectRequestPaymentClient } from "@siglume/direct-request-payment";
|
|
126
|
+
|
|
127
|
+
const siglume = new DirectRequestPaymentClient({
|
|
128
|
+
auth_token: buyerSiglumeBearerToken,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const requirement = await siglume.createPaymentRequirement({
|
|
132
|
+
merchant: "example_merchant",
|
|
133
|
+
amount_minor: order.amount_minor,
|
|
134
|
+
currency: order.currency,
|
|
135
|
+
challenge: order.siglume_challenge,
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Python:
|
|
140
|
+
|
|
141
|
+
```py
|
|
142
|
+
from siglume_direct_request_payment import DirectRequestPaymentClient
|
|
143
|
+
|
|
144
|
+
siglume = DirectRequestPaymentClient(auth_token=buyer_siglume_bearer_token)
|
|
145
|
+
|
|
146
|
+
requirement = siglume.create_payment_requirement(
|
|
147
|
+
merchant="example_merchant",
|
|
148
|
+
amount_minor=order["amount_minor"],
|
|
149
|
+
currency=order["currency"],
|
|
150
|
+
challenge=order["siglume_challenge"],
|
|
151
|
+
)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
If Siglume returns `approve_transaction_request`, execute it first. Then execute
|
|
155
|
+
the payment transaction and verify the receipt.
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
if (requirement.approve_transaction_request) {
|
|
159
|
+
await siglume.executeAllowanceTransaction(requirement, { await_finality: true });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const payment = await siglume.executePaymentTransaction(requirement, {
|
|
163
|
+
await_finality: true,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await siglume.verifyPaymentRequirement(requirement.requirement_id, {
|
|
167
|
+
receipt_id: String(payment.receipt?.receipt_id ?? ""),
|
|
168
|
+
});
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Python:
|
|
172
|
+
|
|
173
|
+
```py
|
|
174
|
+
if requirement.get("approve_transaction_request"):
|
|
175
|
+
siglume.execute_allowance_transaction(requirement, await_finality=True)
|
|
176
|
+
|
|
177
|
+
payment = siglume.execute_payment_transaction(requirement, await_finality=True)
|
|
178
|
+
|
|
179
|
+
siglume.verify_payment_requirement(
|
|
180
|
+
requirement["requirement_id"],
|
|
181
|
+
receipt_id=str((payment.get("receipt") or {}).get("receipt_id") or ""),
|
|
182
|
+
)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## 4. Fulfill from Webhook
|
|
186
|
+
|
|
187
|
+
Use the webhook as the durable signal, not just the browser return path.
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import { verifyDirectRequestPaymentWebhook } from "@siglume/direct-request-payment";
|
|
191
|
+
|
|
192
|
+
const { event } = await verifyDirectRequestPaymentWebhook(
|
|
193
|
+
process.env.SIGLUME_WEBHOOK_SECRET!,
|
|
194
|
+
rawRequestBody,
|
|
195
|
+
siglumeSignatureHeader,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
if (event.type === "direct_payment.confirmed") {
|
|
199
|
+
const data = event.data;
|
|
200
|
+
const order = await orders.findByChallengeHash(String(data.challenge_hash ?? ""));
|
|
201
|
+
if (!order) {
|
|
202
|
+
throw new Error("Unknown Siglume challenge hash");
|
|
203
|
+
}
|
|
204
|
+
await orders.markPaidOnce(order.id, {
|
|
205
|
+
siglume_requirement_id: String(data.requirement_id ?? data.direct_payment_requirement_id ?? ""),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Python:
|
|
211
|
+
|
|
212
|
+
```py
|
|
213
|
+
import os
|
|
214
|
+
|
|
215
|
+
from siglume_direct_request_payment import verify_direct_request_payment_webhook
|
|
216
|
+
|
|
217
|
+
verified = verify_direct_request_payment_webhook(
|
|
218
|
+
os.environ["SIGLUME_WEBHOOK_SECRET"],
|
|
219
|
+
raw_request_body,
|
|
220
|
+
siglume_signature_header,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if verified["event"]["type"] == "direct_payment.confirmed":
|
|
224
|
+
data = verified["event"]["data"]
|
|
225
|
+
order = orders.find_by_challenge_hash(str(data.get("challenge_hash") or ""))
|
|
226
|
+
if not order:
|
|
227
|
+
raise RuntimeError("Unknown Siglume challenge hash")
|
|
228
|
+
orders.mark_paid_once(
|
|
229
|
+
order["id"],
|
|
230
|
+
siglume_requirement_id=str(data.get("requirement_id") or data.get("direct_payment_requirement_id") or ""),
|
|
231
|
+
)
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Failure Handling
|
|
235
|
+
|
|
236
|
+
- `EXTERNAL_402_CHALLENGE_REQUIRED`: the merchant server did not provide a
|
|
237
|
+
challenge.
|
|
238
|
+
- `INVALID_EXTERNAL_402_CHALLENGE`: the amount, currency, merchant, nonce, or
|
|
239
|
+
signature does not match.
|
|
240
|
+
- `EXTERNAL_402_CHALLENGE_ALREADY_USED`: the challenge is already bound to a
|
|
241
|
+
different buyer.
|
|
242
|
+
- `EXTERNAL_402_MERCHANT_BILLING_SETUP_REQUIRED`: merchant onboarding is not
|
|
243
|
+
complete.
|
|
244
|
+
- `EXTERNAL_402_MERCHANT_BILLING_PAST_DUE` or
|
|
245
|
+
`EXTERNAL_402_MERCHANT_BILLING_SUSPENDED`: merchant billing must be fixed
|
|
246
|
+
before new payments can be accepted.
|
|
247
|
+
|
|
248
|
+
## Go-Live Checklist
|
|
249
|
+
|
|
250
|
+
- Challenge secret is only in server-side environment variables.
|
|
251
|
+
- Webhook endpoint receives raw body and verifies `Siglume-Signature`.
|
|
252
|
+
- Orders store `challenge_hash`, `requirement_id`, and fulfillment status.
|
|
253
|
+
- Fulfillment is idempotent.
|
|
254
|
+
- Browser input cannot change the amount or currency.
|
|
255
|
+
- Nonces cannot be reused for separate order attempts.
|
package/docs/pricing.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Pricing
|
|
2
|
+
|
|
3
|
+
This page documents the trial-phase merchant pricing for Siglume Direct Request
|
|
4
|
+
Payment as of 2026-06-11. Pricing can change by agreement or future product
|
|
5
|
+
release; the Siglume platform response is the source of truth for per-payment
|
|
6
|
+
fee data returned at runtime.
|
|
7
|
+
|
|
8
|
+
## Trial Plans
|
|
9
|
+
|
|
10
|
+
| Plan | Monthly fee | Payment fee | Intended starting point |
|
|
11
|
+
| --- | ---: | ---: | --- |
|
|
12
|
+
| Launch | JPY 0 | 0% through 100 payments/month, then 1.8% | Proofs of concept and low-volume trials |
|
|
13
|
+
| Starter | JPY 980 | 1.0% | Early production checkout trials |
|
|
14
|
+
| Growth | JPY 2,980 | 0.7% | Growing EC, booking, membership, and API services |
|
|
15
|
+
| Pro | JPY 9,800 | 0.5% | Higher-volume merchant integrations |
|
|
16
|
+
|
|
17
|
+
The minimum fee is JPY 3 for each fee-bearing payment, including Launch-plan
|
|
18
|
+
payments after the included monthly allowance.
|
|
19
|
+
|
|
20
|
+
If no paid plan is selected during onboarding, the merchant account uses the
|
|
21
|
+
Launch plan. A merchant billing mandate is still required before accepting
|
|
22
|
+
payments so Siglume can collect fees automatically after the 100-payment monthly
|
|
23
|
+
allowance is exceeded.
|
|
24
|
+
|
|
25
|
+
The current Siglume API and merchant registry may still expose the internal
|
|
26
|
+
`billing_plan` value `free` for the Launch tier. Treat `free` as an internal
|
|
27
|
+
compatibility key, not the public plan name.
|
|
28
|
+
|
|
29
|
+
The 100-payment monthly allowance is not a hard processing cap. Payments after
|
|
30
|
+
the allowance can continue when merchant billing is active, and those payments
|
|
31
|
+
are fee-bearing at the Launch overage rate.
|
|
32
|
+
|
|
33
|
+
Per-payment fees are collected during payment settlement through the
|
|
34
|
+
DirectPaymentHub split. The merchant receives the net amount after that fee.
|
|
35
|
+
Monthly base fees are collected separately through the merchant billing mandate.
|
|
36
|
+
|
|
37
|
+
The public trial pricing above is JPY-denominated. If a merchant needs USD/USDC
|
|
38
|
+
settlement, agree the USD merchant billing terms during onboarding; do not infer
|
|
39
|
+
USD monthly or minimum fees from the JPY table.
|
|
40
|
+
|
|
41
|
+
## SDK Behavior
|
|
42
|
+
|
|
43
|
+
The SDK does not calculate merchant invoices or enforce plan limits locally.
|
|
44
|
+
Instead, it exposes billing-related values returned by Siglume, including
|
|
45
|
+
`fee_bps` on a payment requirement. This keeps merchant billing centralized in
|
|
46
|
+
the Siglume platform and avoids stale client-side pricing logic.
|
|
47
|
+
|
|
48
|
+
## Supported Use Cases
|
|
49
|
+
|
|
50
|
+
The trial pricing is intended for:
|
|
51
|
+
|
|
52
|
+
- Small EC checkout
|
|
53
|
+
- Booking and reservation services
|
|
54
|
+
- Membership services
|
|
55
|
+
- Paid API access
|
|
56
|
+
- Agent-to-agent payment experiments
|
package/docs/security.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Security Guide
|
|
2
|
+
|
|
3
|
+
Direct Request Payment is a wallet payment rail. Treat it like payment
|
|
4
|
+
infrastructure, not like a generic API call.
|
|
5
|
+
|
|
6
|
+
## Do Not Expose Secrets
|
|
7
|
+
|
|
8
|
+
These values must stay server-side:
|
|
9
|
+
|
|
10
|
+
- `SIGLUME_DIRECT_PAYMENT_CHALLENGE_SECRET`
|
|
11
|
+
- `SIGLUME_WEBHOOK_SECRET`
|
|
12
|
+
- any merchant administrative credentials
|
|
13
|
+
|
|
14
|
+
The buyer-facing browser may receive the signed `challenge` string, but never
|
|
15
|
+
the secret that produced it.
|
|
16
|
+
|
|
17
|
+
## Bind the Order Server-Side
|
|
18
|
+
|
|
19
|
+
The HMAC challenge covers:
|
|
20
|
+
|
|
21
|
+
```text
|
|
22
|
+
merchant:amount_minor:currency:nonce
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Use a nonce derived from a durable order payment attempt, for example
|
|
26
|
+
`order_123-attempt_1`. The nonce must not contain `:` because the platform
|
|
27
|
+
challenge is encoded as `scheme:nonce:signature`. Store the returned
|
|
28
|
+
`challenge_hash` on the order. When a
|
|
29
|
+
webhook arrives, look up the order by `challenge_hash`.
|
|
30
|
+
|
|
31
|
+
## Do Not Trust Browser Amounts
|
|
32
|
+
|
|
33
|
+
The merchant server owns:
|
|
34
|
+
|
|
35
|
+
- SKU or plan
|
|
36
|
+
- amount in minor units
|
|
37
|
+
- currency
|
|
38
|
+
- nonce
|
|
39
|
+
|
|
40
|
+
If a browser says the order total is 1200 JPY, treat that as display state only.
|
|
41
|
+
Re-read the order server-side before generating the challenge.
|
|
42
|
+
|
|
43
|
+
## Webhook Verification
|
|
44
|
+
|
|
45
|
+
Verify the `Siglume-Signature` header using the raw request body. Do not parse
|
|
46
|
+
and re-stringify JSON before verification.
|
|
47
|
+
|
|
48
|
+
The SDK expects the Siglume signature format:
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
t=<unix timestamp>,v1=<hex hmac sha256>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The signed payload is:
|
|
55
|
+
|
|
56
|
+
```text
|
|
57
|
+
<timestamp>.<raw body>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The default tolerance is 300 seconds.
|
|
61
|
+
|
|
62
|
+
## Idempotency
|
|
63
|
+
|
|
64
|
+
Fulfill exactly once per order. Store at least:
|
|
65
|
+
|
|
66
|
+
- order id
|
|
67
|
+
- challenge hash
|
|
68
|
+
- Siglume requirement id
|
|
69
|
+
- on-chain receipt id or transaction hash if present
|
|
70
|
+
- fulfillment state
|
|
71
|
+
|
|
72
|
+
Duplicate webhook deliveries and manual redelivery can occur. A duplicate
|
|
73
|
+
webhook with the same requirement id must not ship the order twice.
|
|
74
|
+
|
|
75
|
+
## What Direct Request Payment Is Not
|
|
76
|
+
|
|
77
|
+
Direct Request Payment is not:
|
|
78
|
+
|
|
79
|
+
- stored value
|
|
80
|
+
- prepaid points
|
|
81
|
+
- escrow
|
|
82
|
+
- a platform balance
|
|
83
|
+
- a card payment fallback
|
|
84
|
+
|
|
85
|
+
It is a one-request wallet payment gate backed by an on-chain receipt.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import {
|
|
3
|
+
createDirectRequestPaymentChallenge,
|
|
4
|
+
DirectRequestPaymentClient,
|
|
5
|
+
verifyDirectRequestPaymentWebhook,
|
|
6
|
+
} from "@siglume/direct-request-payment";
|
|
7
|
+
|
|
8
|
+
const app = express();
|
|
9
|
+
const port = Number(process.env.PORT || 3000);
|
|
10
|
+
|
|
11
|
+
// Use JSON for normal routes. Use raw body only on the webhook route.
|
|
12
|
+
app.use((req, res, next) => {
|
|
13
|
+
if (req.path === "/siglume/webhook") {
|
|
14
|
+
next();
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
express.json()(req, res, next);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const orders = new Map<string, any>();
|
|
21
|
+
|
|
22
|
+
const asyncRoute =
|
|
23
|
+
(handler: express.RequestHandler): express.RequestHandler =>
|
|
24
|
+
(req, res, next) => {
|
|
25
|
+
Promise.resolve(handler(req, res, next)).catch(next);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
app.post("/checkout/siglume/start", asyncRoute(async (req, res) => {
|
|
29
|
+
const orderId = String(req.body.order_id || "");
|
|
30
|
+
const order = orders.get(orderId);
|
|
31
|
+
if (!order) {
|
|
32
|
+
res.status(404).json({ error: "order_not_found" });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
order.payment_attempt = Number(order.payment_attempt || 0) + 1;
|
|
37
|
+
const challenge = await createDirectRequestPaymentChallenge({
|
|
38
|
+
merchant: "example_merchant",
|
|
39
|
+
amount_minor: order.amount_minor,
|
|
40
|
+
currency: order.currency,
|
|
41
|
+
secret: process.env.SIGLUME_DIRECT_PAYMENT_CHALLENGE_SECRET!,
|
|
42
|
+
nonce: `${order.id}-attempt_${order.payment_attempt}`,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
order.siglume_challenge_hash = challenge.challenge_hash;
|
|
46
|
+
order.siglume_payment_status = "pending";
|
|
47
|
+
|
|
48
|
+
res.json({
|
|
49
|
+
order_id: order.id,
|
|
50
|
+
amount_minor: order.amount_minor,
|
|
51
|
+
currency: order.currency,
|
|
52
|
+
siglume_challenge: challenge.challenge,
|
|
53
|
+
});
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
app.post("/checkout/siglume/pay", asyncRoute(async (req, res) => {
|
|
57
|
+
const order = orders.get(String(req.body.order_id || ""));
|
|
58
|
+
if (!order) {
|
|
59
|
+
res.status(404).json({ error: "order_not_found" });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// In production, obtain this from the authenticated buyer's Siglume session
|
|
64
|
+
// or a hosted Siglume payment confirmation flow. Do not use a merchant secret
|
|
65
|
+
// to charge a customer wallet.
|
|
66
|
+
const siglume = new DirectRequestPaymentClient({
|
|
67
|
+
auth_token: String(req.headers.authorization || "").replace(/^Bearer\s+/i, ""),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const requirement = await siglume.createPaymentRequirement({
|
|
71
|
+
merchant: "example_merchant",
|
|
72
|
+
amount_minor: order.amount_minor,
|
|
73
|
+
currency: order.currency,
|
|
74
|
+
challenge: String(req.body.siglume_challenge || ""),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
res.json({ requirement });
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
app.post("/siglume/webhook", express.raw({ type: "application/json" }), asyncRoute(async (req, res) => {
|
|
81
|
+
const header = String(req.headers["siglume-signature"] || "");
|
|
82
|
+
const { event } = await verifyDirectRequestPaymentWebhook(
|
|
83
|
+
process.env.SIGLUME_WEBHOOK_SECRET!,
|
|
84
|
+
req.body,
|
|
85
|
+
header,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (event.type === "direct_payment.confirmed") {
|
|
89
|
+
const challengeHash = String(event.data.challenge_hash || "");
|
|
90
|
+
const order = [...orders.values()].find((item) => item.siglume_challenge_hash === challengeHash);
|
|
91
|
+
if (order) {
|
|
92
|
+
order.siglume_payment_status = "paid";
|
|
93
|
+
order.siglume_requirement_id = event.data.requirement_id || event.data.direct_payment_requirement_id;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
res.status(204).send();
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
101
|
+
const message = error instanceof Error ? error.message : "internal_error";
|
|
102
|
+
res.status(500).json({ error: message });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
app.listen(port);
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@siglume/direct-request-payment",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Merchant SDK for Siglume Direct Request Payment checkout integrations",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/taihei-05/siglume-direct-request-payment.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=18"
|
|
13
|
+
},
|
|
14
|
+
"sideEffects": false,
|
|
15
|
+
"main": "./dist/index.cjs",
|
|
16
|
+
"module": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"default": "./dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"require": {
|
|
26
|
+
"types": "./dist/index.d.cts",
|
|
27
|
+
"default": "./dist/index.cjs"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"docs",
|
|
38
|
+
"examples",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsup",
|
|
44
|
+
"lint": "npm run typecheck",
|
|
45
|
+
"prepack": "npm run build",
|
|
46
|
+
"prepublishOnly": "npm run build",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"pack:check": "npm pack --json"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/node": "^20.16.5",
|
|
53
|
+
"tsup": "^8.3.0",
|
|
54
|
+
"typescript": "^5.6.3",
|
|
55
|
+
"vitest": "^2.1.8"
|
|
56
|
+
}
|
|
57
|
+
}
|