@bitpal/checkout 0.3.0 → 0.6.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BitPal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,392 +1,222 @@
1
- # @bitpal/checkout
2
-
3
- BitPal Checkout SDK — 크립토 결제 세션 생성, 외부 on-chain 결제 검증, gasless 결제, 환불, 수수료 미리보기를 위한 공식 TypeScript SDK.
4
-
5
- ## 설치
6
-
7
- ```bash
8
- pnpm add @bitpal/checkout
9
- ```
10
-
11
- ## ⚠️ 금액 형식 (꼭 읽어주세요)
12
-
13
- BitPal API는 모든 amount를 **atomic μUSDC 정수 문자열**로 받습니다 (USDC/USDT 6 decimals).
14
-
15
- | 의미 | 보내는 값 |
16
- |------|-----------|
17
- | $1.00 | `"1000000"` |
18
- | $10.00 | `"10000000"` |
19
- | $29.00 | `"29000000"` |
20
- | $0.50 | `"500000"` |
21
-
22
- `"29.00"`처럼 decimal 표기로 보내면 400 에러가 떨어집니다. SDK가 제공하는 `toAtomicUSDC()` 헬퍼를 쓰세요:
23
-
24
- ```ts
25
- import { toAtomicUSDC, fromAtomicUSDC } from '@bitpal/checkout';
26
-
27
- toAtomicUSDC('29.00'); // → '29000000'
28
- toAtomicUSDC('0.5'); // → '500000'
29
- fromAtomicUSDC('1500000'); // → '1.50'
30
- ```
31
-
32
- 이 정책은 부동소수 정밀도 손실 방지 때문이며, 모든 BitPal 라우트에 일관 적용됩니다.
33
-
34
- ---
35
-
36
- ## 환경 (Environments)
37
-
38
- BitPal Checkout 은 두 개의 직교 (orthogonal) 축으로 환경이 결정됩니다.
39
-
40
- | 축 | 값 | 결정 방식 | 영향 |
41
- |----|----|---------|------|
42
- | **mode** | `TEST` / `LIVE` | API 키 prefix (`bp_test_*` / `bp_live_*`) | 어떤 컨트랙트 / 정산 / webhook environment 가 사용될지 |
43
- | **API host** | production / local | `BitPal({ baseUrl })` | 어떤 BitPal 백엔드를 호출할지 (실서비스 vs 본인 dev 머신) |
44
-
45
- 조합 매트릭스:
46
-
47
- | 시나리오 | `baseUrl` | API 키 | 실제 체인 |
48
- |---------|-----------|--------|----------|
49
- | Local 개발 (anvil) | `http://localhost:3100` | `bp_test_*` | 로컬 Hardhat/Anvil 체인 (`http://127.0.0.1:8545`) |
50
- | Testnet 통합 | `https://api.bitpal.io` | `bp_test_*` | Base Sepolia 등 퍼블릭 testnet |
51
- | Production | `https://api.bitpal.io` | `bp_live_*` | Base Mainnet |
52
-
53
- ```ts
54
- import { BitPal } from '@bitpal/checkout';
55
-
56
- // 1) Production / testnet (BitPal 호스티드 백엔드)
57
- const bitpal = new BitPal({
58
- apiKey: process.env.BITPAL_API_KEY!, // bp_test_* (testnet) 또는 bp_live_* (mainnet)
59
- });
60
-
61
- // 2) Local 개발 (본인 머신의 BitPal API + anvil)
62
- const bitpalLocal = new BitPal({
63
- apiKey: process.env.BITPAL_API_KEY!, // bp_test_* 필수
64
- baseUrl: 'http://localhost:3100',
65
- });
66
- ```
67
-
68
- > 어떤 체인이든 SDK 호출 시점에 `chain: 'base'` (또는 다른 지원 체인) 만 지정하면 됩니다. mode 가 mainnet/testnet/local 컨트랙트 주소 매핑을 결정하므로 클라이언트는 체인 _이름_ 만 알면 충분합니다.
69
-
70
- ## 사용법
71
-
72
- ### 기본 설정
73
-
74
- ```ts
75
- import { BitPal } from '@bitpal/checkout';
76
-
77
- // API 키는 BitPal 콘솔 → Developers → API Keys 에서 발급
78
- const bitpal = new BitPal({ apiKey: process.env.BITPAL_API_KEY! });
79
- ```
80
-
81
- ### 체크아웃 세션 생성
82
-
83
- ```ts
84
- import { BitPal, toAtomicUSDC } from '@bitpal/checkout';
85
-
86
- const bitpal = new BitPal({ apiKey: process.env.BITPAL_API_KEY! });
87
-
88
- const { data: session } = await bitpal.checkout.createSession({
89
- line_items: [
90
- { name: 'Pro Plan', amount: toAtomicUSDC('29.00'), currency: 'USDC' },
91
- ],
92
- pay_chain: 'eip155:8453', // Base
93
- expires_in_seconds: 1800,
94
- success_url: 'https://yoursite.com/order-complete',
95
- cancel_url: 'https://yoursite.com/cart',
96
- });
97
-
98
- // buyer를 이 URL로 redirect → BitPal이 호스팅하는 결제 페이지
99
- console.log(session.url);
100
- ```
101
-
102
- ### 환불 (auto — 에스크로 보관 중)
103
-
104
- ```ts
105
- import { randomUUID } from 'node:crypto';
106
- import { BitPal, toAtomicUSDC } from '@bitpal/checkout';
107
-
108
- const bitpal = new BitPal(process.env.BITPAL_API_KEY!);
109
-
110
- // 전액 환불 — amount 미지정
111
- const { data: full } = await bitpal.checkout.refundSession(
112
- sessionId,
113
- { reason: 'customer_requested' },
114
- { idempotencyKey: randomUUID() }, // 동일 키 재시도 시 같은 결과 반환
115
- );
116
- console.log(full.refunded, full.txHash); // true, '0x...' (또는 sim_xxx)
117
-
118
- // 부분 환불 — amount 명시
119
- const { data: partial } = await bitpal.checkout.refundSession(
120
- sessionId,
121
- { amount: toAtomicUSDC('5.00'), reason: 'partial_damage' },
122
- { idempotencyKey: randomUUID() },
123
- );
124
- console.log(partial.type); // 'partial'
125
- ```
126
-
127
- 머천트가 `frozen` 상태면 환불은 허용되지만, `suspended` 상태에서는 차단된다 (`merchant-status-guard`).
128
-
129
- ### 환불 기록 (record — settled 후 manual offchain)
130
-
131
- ```ts
132
- // 정산이 끝난 세션은 escrow에 자금이 없음 → 머천트가 외부 지갑에서 직접 송금하고
133
- // tx_hash를 입력해 환불을 DB-only로 기록한다.
134
- await bitpal.checkout.recordRefund(
135
- sessionId,
136
- {
137
- refund_amount: toAtomicUSDC('5.00'),
138
- tx_hash: '0x...실제 송금 tx hash',
139
- reason: 'manual_offchain_refund',
140
- },
141
- { idempotencyKey: randomUUID() },
142
- );
143
- ```
144
-
145
- ### 환불 내역 조회
146
-
147
- ```ts
148
- const { data: refunds } = await bitpal.checkout.listRefunds(sessionId);
149
- // auto + record 통합 list, 시간 역순
150
- ```
151
-
152
- ### 수수료 미리보기
153
-
154
- ```ts
155
- // $100 결제의 수수료 계산
156
- const { data: fee } = await bitpal.checkout.previewFee(toAtomicUSDC('100'));
157
- // fee.fee_amount === "1500000" (1.5% = $1.50)
158
- // fee.net_amount === "98500000" (= $98.50)
159
-
160
- console.log(`수수료: $${fromAtomicUSDC(fee.fee_amount)}`);
161
- ```
162
-
163
- ### Gasless external checkout payment
164
-
165
- Use `payWithAuthorization()` when your merchant UI collects an EIP-3009 USDC authorization from the buyer and wants BitPal to submit the escrow deposit with the platform relayer.
166
-
167
- ```ts
168
- const { data: session } = await bitpal.checkout.getSession(sessionId);
169
- const cfg = session.external_payment_config;
170
-
171
- // Sign EIP-3009 typed data in your wallet flow using cfg.token_address,
172
- // cfg.escrow_contract_address, cfg.amount, and cfg.payment_id.
173
- const authorization = {
174
- from: '0xBuyer',
175
- validAfter: '0',
176
- validBefore: '1778650000',
177
- authNonce: '0x...',
178
- v: 27,
179
- r: '0x...',
180
- s: '0x...',
181
- };
182
-
183
- const { data: payment } = await bitpal.checkout.payWithAuthorization(sessionId, {
184
- chain: cfg?.chain ?? 'base',
185
- session_token: session.session_token!,
186
- authorization,
187
- });
188
-
189
- console.log(payment.status, payment.tx_hash);
190
- ```
191
-
192
- `payWithAuthorization()` calls `POST /v1/checkout/sessions/:id/submit-payment`. The buyer signs only the authorization; BitPal's relayer submits `CheckoutEscrow.depositWithAuthorization()` and pays gas.
193
-
194
- SDK 의 반환값(`status`, `tx_hash`) 만으로 merchant 백엔드가 주문을 finalize 할 수 있습니다. webhook 등록은 **선택** — SDK 호출 없이 자동 알림이 필요한 머천트 백엔드 (비동기 잡, 다른 마이크로서비스 fan-out 등) 만 등록하면 됩니다.
195
-
196
- ### External on-chain payment verification (submitExternalPayment)
197
-
198
- When the buyer deposits into `CheckoutEscrow` from their own wallet, merchant UI, or your own backend — outside the BitPal hosted page and outside `payWithAuthorization()` — submit the resulting `tx_hash` to BitPal for verification.
199
-
200
- ```ts
201
- const { data: result } = await bitpal.checkout.submitExternalPayment(sessionId, {
202
- tx_hash: '0xb81f1eb2cc8a4b32a233865c595166df563f8f009e319048f8d079abd0f3953d',
203
- chain: 'base',
204
- });
205
-
206
- // result.validation_status: 'valid' | 'invalid' | 'underpaid' | 'overpaid' | 'duplicate'
207
- // result.session_status: 'paid' | 'unresolved_underpaid' | 'unresolved_overpaid' | ...
208
- // result.observation_id: uuid of the checkout_payment_observations row
209
- // result.from_address: payer wallet (derived from PaymentDeposited event)
210
- // result.amount: atomic μUSDC received (derived from event)
211
- ```
212
-
213
- Behaviour:
214
-
215
- - BitPal fetches the receipt, parses `CheckoutEscrow.PaymentDeposited`, and verifies `paymentId == toBytes32(sessionId)`, merchant wallet, USDC token, and amount tolerance.
216
- - The same `(chain_id, tx_hash, log_index)` is rejected with `validation_status: 'duplicate'` if it has already been submitted for a different session.
217
- - Re-submitting the same `tx_hash` for the same session is idempotent — you get the same observation row back.
218
- - If you're using the Ponder Mode B indexer (`apps/indexer`) the indexer calls this endpoint automatically when it sees a `PaymentDeposited` event, so you do not need to submit manually.
219
-
220
- ```ts
221
- // Read the on-chain verification history for a session
222
- const { data: observations } = await bitpal.checkout.getExternalPaymentStatus(sessionId);
223
- const latest = observations[0];
224
- if (latest?.finality_status === 'final_confirmed') {
225
- // 12 confirmations reached — safe to fulfil orders that require finality
226
- }
227
- ```
228
-
229
- `getExternalPaymentStatus()` is the authenticated read counterpart for tenant-scoped clients. It returns the same `payment_observations` array that the merchant console session detail page shows under "On-chain Verification".
230
-
231
- #### Choosing between SDK payment methods
232
-
233
- | Method | Buyer signs | Who calls `CheckoutEscrow.deposit*` | Use when |
234
- |--------|------------|--------------------------------------|----------|
235
- | Hosted checkout (`createSession` → redirect to `session.url`) | EIP-3009 inside BitPal page | BitPal relayer | You want the simplest integration and don't need a buyer UI |
236
- | `payWithAuthorization()` | EIP-3009 inside your UI | BitPal relayer | Buyer stays in merchant UI, gasless for buyer |
237
- | `submitExternalPayment()` | Buyer signs the deposit tx in their wallet | Buyer wallet or your backend | Buyer pays their own gas, or you have a non-BitPal flow that emits `PaymentDeposited` |
238
-
239
- All three paths land in the same `CheckoutEscrow.PaymentDeposited` event and trigger the same set of webhooks (`checkout.session.paid`, `checkout.session.unresolved`, `checkout.payment.finalized`, `checkout.payment.revoked`).
240
-
241
- ### 웹훅 검증
242
-
243
- ```ts
244
- import express from 'express';
245
- import { verifyWebhookSignature, parseWebhookEvent, WEBHOOK_HEADERS } from '@bitpal/checkout';
246
-
247
- const app = express();
248
-
249
- // raw body 필수 — express.json() 쓰지 말 것
250
- app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
251
- const signature = req.header(WEBHOOK_HEADERS.signature) ?? ''; // 'X-Webhook-Signature-256'
252
- const isValid = await verifyWebhookSignature(
253
- req.body.toString(),
254
- signature,
255
- process.env.BITPAL_WEBHOOK_SECRET!,
256
- );
257
- if (!isValid) return res.status(400).send('invalid signature');
258
-
259
- const event = parseWebhookEvent(req.body.toString());
260
- switch (event.event) {
261
- case 'checkout.session.paid':
262
- // DB에 주문 완료 처리 (idempotent — at-least-once 전달)
263
- break;
264
- case 'checkout.payment.failed':
265
- // 실패 처리
266
- break;
267
- }
268
- res.status(200).send('ok');
269
- });
270
- ```
271
-
272
- #### Webhook 헤더 (canonical)
273
-
274
- BitPal backend가 모든 webhook delivery에 보내는 표준 헤더:
275
-
276
- | 헤더 | 값 | 용도 |
277
- |------|----|------|
278
- | `X-Webhook-Signature-256` | `sha256=<hex>` | HMAC-SHA256 서명 (raw body 기준) |
279
- | `X-Webhook-Timestamp` | ISO 시각 | envelope.created_at과 동일 |
280
- | `X-Webhook-Event` | `checkout.session.paid` 등 | canonical event name |
281
- | `X-Webhook-Id` | `evt_...` | event_id (replay 시 보존) |
282
- | `X-Webhook-Delivery-Id` | `dlv_...` | delivery row id |
283
-
284
- `WEBHOOK_HEADERS` 상수로 SDK에서 import 가능 — 헤더 이름 drift 방지.
285
-
286
- ### ⚠️ At-least-once delivery — 머천트 idempotency 책임
287
-
288
- BitPal webhook은 **at-least-once delivery**다. 같은 이벤트가 재시도/replay/race로 인해 두 번 이상 도달할 수 있으며, 머천트 측에서 반드시 idempotent하게 처리해야 한다.
289
-
290
- **필수 패턴**:
291
- 1. signature 검증 후
292
- 2. `X-Webhook-Id` (또는 `X-Webhook-Delivery-Id`) 기준으로 dedup
293
- 3. 이미 처리한 event는 200 OK만 반환하고 부작용 없이 종료
294
-
295
- ```ts
296
- import { verifyWebhookSignature, parseWebhookEvent, WEBHOOK_HEADERS } from '@bitpal/checkout';
297
-
298
- app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
299
- // 1. signature 검증
300
- const sig = req.header(WEBHOOK_HEADERS.signature) ?? '';
301
- const ok = await verifyWebhookSignature(req.body.toString(), sig, SECRET);
302
- if (!ok) return res.status(400).send('invalid signature');
303
-
304
- // 2. dedup — X-Webhook-Id 기준
305
- const eventId = req.header(WEBHOOK_HEADERS.eventId);
306
- const seen = await db.webhook_events.findUnique({ where: { event_id: eventId } });
307
- if (seen) return res.status(200).send('already processed');
308
-
309
- // 3. event 처리 + 처리 기록을 동일 트랜잭션에서
310
- const event = parseWebhookEvent(req.body.toString());
311
- await db.$transaction(async (tx) => {
312
- await applyEvent(tx, event); // 비즈니스 로직
313
- await tx.webhook_events.create({ data: { event_id: eventId } }); // dedup 키 기록
314
- });
315
-
316
- res.status(200).send('ok');
317
- });
318
- ```
319
-
320
- dedup 키로 `X-Webhook-Id` (event 단위) 또는 `X-Webhook-Delivery-Id` (delivery 단위) 둘 다 가능 — 이벤트 단위 dedup이 일반적으로 더 안전하다 (replay도 같은 event_id 보존).
321
-
322
- ---
323
-
324
- ## 설정 옵션
325
-
326
- | 옵션 | 타입 | 기본값 | 설명 |
327
- |------|------|--------|------|
328
- | `apiKey` | `string` | (필수) | API 키 (`bp_test_...` 또는 `bp_live_...`) |
329
- | `baseUrl` | `string` | `https://api.bitpal.io` | API 기본 URL (로컬: `http://localhost:3100`) |
330
- | `timeout` | `number` | `30000` | 요청 타임아웃 (ms) |
331
-
332
- ## 웹훅 이벤트
333
-
334
- SDK `CHECKOUT_WEBHOOK_EVENTS` 상수와 1:1 정합. 새 이벤트 추가 시 backend와 동시 갱신.
335
-
336
- | 이벤트 | 설명 |
337
- |--------|------|
338
- | `checkout.session.created` | 세션 생성됨 |
339
- | `checkout.session.paid` | 결제 완료 (에스크로 락) |
340
- | `checkout.session.settled` | 정산 완료 (에스크로 해제) |
341
- | `checkout.session.expired` | 세션 만료 |
342
- | `checkout.session.unresolved` | 세션 미해결 (수동 정정 필요) |
343
- | `checkout.session.release_deferred` | 머천트 frozen/suspended로 release 보류 |
344
- | `checkout.payment.failed` | 결제 실패 |
345
- | `checkout.payment.finalized` | 외부 온체인 결제 최종 확인 |
346
- | `checkout.payment.revoked` | 외부 온체인 결제 reorg 취소 |
347
- | `checkout.refund.recorded` | 환불 기록 (settled 후 manual) |
348
- | `checkout.refund.completed` | 환불 완료 (전액) |
349
- | `checkout.refund.partial` | 부분 환불 |
350
-
351
- ## 통합 흐름
352
-
353
- ```
354
- 머천트 백엔드 BitPal API Buyer
355
- │ │ │
356
- │ 1. createSession() │ │
357
- │ ──────────────────────────► │ │
358
- │ 2. { url } 응답 │ │
359
- │ ◄──────────────────────────┤ │
360
- │ │ │
361
- │ 3. buyer를 url로 redirect ───────────────────► │
362
- │ │ │
363
- │ │ 4. 결제 진행 │
364
- │ │ ◄────────────────┤
365
- │ │ │
366
- │ 5. POST /merchant/webhook │ │
367
- │ ◄─── checkout.session.paid ─┤ │
368
- │ 6. signature 검증 │ │
369
- │ 7. DB에 주문 완료 처리 │ │
370
- ```
371
-
372
- ## 실행 가능한 예제
373
-
374
- `examples/` 디렉토리에 통합 데모가 들어있습니다:
375
-
376
- - `examples/create-session.mjs` — 세션 생성 → 결제 URL 출력
377
- - `examples/webhook-receiver.mjs` — webhook 수신 + 서명 검증
378
-
379
- ```bash
380
- cd examples
381
- node create-session.mjs
382
- ```
383
-
384
- ## 빌드
385
-
386
- ```bash
387
- pnpm --filter @bitpal/checkout build
388
- ```
389
-
390
- ## 라이선스
391
-
392
- Private
1
+ # @bitpal/checkout
2
+
3
+ BitPal Checkout SDK — **non-custodial** crypto checkout. Create sessions, issue per-order deposit
4
+ addresses, poll status, and verify signed webhooks. BitPal never custodies funds: each order gets a
5
+ deposit address cryptographically committed to your wallet, and payments are swept directly to you.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add @bitpal/checkout
11
+ # npm i @bitpal/checkout / yarn add @bitpal/checkout
12
+ ```
13
+
14
+ ## Amounts — just pass a human number
15
+
16
+ Use **`amount`** in line items — a plain USD string like `"20"`, `"0.5"`, `"29.00"`. No decimals to compute.
17
+
18
+ ```ts
19
+ line_items: [{ name: 'Pro', amount: '29', currency: 'USDC' }]
20
+ ```
21
+
22
+ **The amount is a USD price — it does NOT depend on the chain or token decimals.** Whether the buyer pays with USDC (6dp) on Base or USDT (18dp) on BNB, you pass the same `amount`. BitPal rescales to the buyer's chosen token at deposit time. So you never compute per-token decimals. (Prices via `prices.create` take the same `amount`.)
23
+
24
+ <details><summary>Advanced: raw atomic amounts</summary>
25
+
26
+ If you already hold **atomic μUSD integer strings** ($1 = `"1000000"`), pass `amount_atomic` instead (mutually exclusive with `amount`):
27
+
28
+ ```
29
+ $1.00 → "1000000" $29.00 → "29000000" $0.50 → "500000"
30
+ ```
31
+
32
+ Helpers for atomic conversion (e.g. reading `amount_total` back): `toAtomic(v, decimals)` / `fromAtomic(v, decimals)` — read `decimals` from `payment_options[]`/`token_decimals` (6 for USDC/USDT, 18 for BNB USDT). `toAtomicUSDC`/`fromAtomicUSDC` are 6dp shortcuts.
33
+ </details>
34
+
35
+ ## Environments
36
+
37
+ API keys are environment-scoped:
38
+
39
+ - `bp_test_...` — Test mode (testnets). No real funds.
40
+ - `bp_live_...` — Live mode (mainnets).
41
+
42
+ Get keys in the console under **Developers → API Keys**. The key you use decides the environment —
43
+ there is no separate flag.
44
+
45
+ ## Usage
46
+
47
+ ### Setup
48
+
49
+ ```ts
50
+ import { BitPal } from '@bitpal/checkout';
51
+ // (optional atomic helpers if you need them: import { toAtomic, fromAtomic } from '@bitpal/checkout')
52
+
53
+ const bitpal = new BitPal(process.env.BITPAL_API_KEY!); // bp_test_... / bp_live_...
54
+ ```
55
+
56
+ > Works with both ESM (`import`) and CommonJS (`const { BitPal } = require('@bitpal/checkout')`).
57
+
58
+ ### Create a session → redirect to the hosted checkout (simplest)
59
+
60
+ `createSession` returns a `checkout_url`. Redirect the buyer there — BitPal hosts the payment page,
61
+ shows the per-order deposit address + QR, and confirms the payment on-chain.
62
+
63
+ ```ts
64
+ const { data: session } = await bitpal.checkout.createSession({
65
+ line_items: [
66
+ { name: 'Pro Plan', amount: '29', currency: 'USDC' }, // just the USD number
67
+ ],
68
+ pay_chain: 'eip155:84532', // Base Sepolia (test mode). Live mode: 'eip155:8453' (Base mainnet).
69
+ // Or use allowed_pay_chains for a buyer-picked chain.
70
+ expires_in_seconds: 1800,
71
+ success_url: 'https://yourstore.com/thanks',
72
+ });
73
+
74
+ // Redirect the buyer here — the hosted page handles the deposit address.
75
+ redirect(session.checkout_url!);
76
+ ```
77
+
78
+ `pay_chain` (single fixed chain) and `allowed_pay_chains` (buyer picks) are mutually exclusive.
79
+
80
+ ### Self-hosted flow: issue a deposit address yourself
81
+
82
+ If you render your own payment UI, issue the per-order deposit address and show it to the buyer.
83
+ The buyer then sends the exact amount from **any wallet or exchange** — no wallet connection, no
84
+ signature; they pay their own network gas. A watcher detects the deposit and a keeper sweeps it to
85
+ you.
86
+
87
+ ```ts
88
+ const { data } = await bitpal.checkout.issueDepositAddress(session.id, {
89
+ session_token: session.session_token!, // from createSession
90
+ refundAddress: '0xBuyerWallet...', // VM-matched (EVM 0x… / Tron T… / Solana base58); committed, immutable after issuance
91
+ chain: 'eip155:84532', // match the session's chain (test: Base Sepolia / live: eip155:8453).
92
+ // only needed for multi-option (allowed_pay_chains) sessions
93
+ token: 'USDC',
94
+ });
95
+
96
+ console.log(data.depositAddress); // buyer sends the exact amount here
97
+ console.log(data.expectedAmount); // atomic μUSDC — display this exact amount
98
+ ```
99
+
100
+ > `refundAddress` is committed into the deposit address and **cannot change** after issuance
101
+ > (over / under / late payments auto-refund there on-chain).
102
+
103
+ ### Poll status (buyer UX)
104
+
105
+ ```ts
106
+ const { data } = await bitpal.checkout.getStatus(session.id); // no auth needed
107
+ // status: awaiting_deposit → deposit_detected → paid_confirmed → swept
108
+ ```
109
+
110
+ Polling is for buyer-facing UX. For **server-side** confirmation, trust the webhook (below).
111
+
112
+ ### Preview the fee
113
+
114
+ ```ts
115
+ const { data: fee } = await bitpal.checkout.previewFee(toAtomicUSDC('29.00'));
116
+ console.log(fromAtomicUSDC(fee.fee_amount)); // e.g. "0.43"
117
+ ```
118
+
119
+ ## Webhooks
120
+
121
+ Register an endpoint in **Developers → Webhooks**. Verify every delivery with the shared secret
122
+ (`whsec_...`) before trusting it.
123
+
124
+ ```ts
125
+ import express from 'express';
126
+ import { verifyWebhookSignature, parseWebhookEvent, WEBHOOK_HEADERS } from '@bitpal/checkout';
127
+
128
+ const app = express();
129
+
130
+ app.post(
131
+ '/webhook',
132
+ express.raw({ type: 'application/json' }), // raw body — do NOT use express.json() (the raw body is signed)
133
+ async (req, res) => {
134
+ const raw = req.body.toString('utf8'); // raw body — parsing/re-serializing breaks the signature
135
+ const ok = await verifyWebhookSignature(
136
+ raw,
137
+ req.header(WEBHOOK_HEADERS.signature) ?? '', // 'sha256=<hex>'
138
+ process.env.BITPAL_WEBHOOK_SECRET!, // whsec_...
139
+ { timestamp: req.header(WEBHOOK_HEADERS.timestamp) ?? '' }, // replay defense — REQUIRED
140
+ );
141
+ if (!ok) return res.status(400).send('invalid signature');
142
+
143
+ const event = parseWebhookEvent(raw);
144
+
145
+ // ⚠️ At-least-once delivery — dedup on the SIGNED body id (event.id), not the
146
+ // X-Webhook-Id header (unsigned → forgeable). Persist the key in the same txn.
147
+ if (await alreadyProcessed(event.id)) return res.status(200).send('already processed');
148
+
149
+ switch (event.event) {
150
+ case 'checkout.session.paid': // on-chain confirmed
151
+ case 'checkout.session.settled': // swept to your wallet
152
+ await fulfill(event.data.session_id);
153
+ break;
154
+ }
155
+ res.status(200).send('ok');
156
+ },
157
+ );
158
+ ```
159
+
160
+ ### Events
161
+
162
+ | Event | Meaning |
163
+ |-------|---------|
164
+ | `checkout.session.created` | Session created |
165
+ | `checkout.session.deposit_detected` | Deposit seen on-chain (awaiting confirmations) |
166
+ | `checkout.session.paid` | Payment confirmed on-chain |
167
+ | `checkout.session.settled` | Funds swept to your wallet |
168
+ | `checkout.session.expired` | Session expired unpaid |
169
+ | `checkout.session.unresolved` | Underpaid / overpaid / wrong token — needs review |
170
+ | `checkout.refund.recorded` | An on-chain auto-refund (over / under / late payment) was recorded |
171
+
172
+ ### ⚠️ Replay defense + at-least-once delivery
173
+
174
+ The signature covers `` `${X-Webhook-Timestamp}.${rawBody}` `` (HMAC-SHA256), and `verifyWebhookSignature`
175
+ **requires** the `X-Webhook-Timestamp` header and rejects deliveries outside a ±5 min freshness window
176
+ (override with `toleranceSec`). This blocks replay of a captured request.
177
+
178
+ BitPal may still deliver the **same event more than once** within the window. Deduplicate on the
179
+ **signed body id** (`event.id` from `parseWebhookEvent`), **not** the `X-Webhook-Id` header (the header
180
+ is not signed, so it is forgeable). Persist the dedup key in the **same transaction** as your side
181
+ effect; handlers must be safe to run twice (UPSERT, not INSERT).
182
+
183
+ ## Config options
184
+
185
+ ```ts
186
+ new BitPal(apiKey); // string shorthand — defaults to https://api.bitpal.io
187
+ new BitPal({ apiKey, baseUrl: 'https://…' }); // config object — override baseUrl (self-hosted / staging)
188
+ ```
189
+
190
+ ## Integration flow
191
+
192
+ 1. Backend → `bitpal.checkout.createSession(...)` → get `checkout_url` (+ `session_token`).
193
+ 2. Redirect the buyer to `checkout_url` (or render your own UI + `issueDepositAddress`).
194
+ 3. Buyer sends the exact amount to the deposit address from any wallet / exchange.
195
+ 4. BitPal detects + confirms on-chain, then sweeps directly to your wallet (non-custodial).
196
+ 5. Your webhook receives `checkout.session.paid` / `settled` → fulfill the order.
197
+
198
+ ## Runnable examples
199
+
200
+ The source repo's `examples/` directory has runnable references (not shipped in the npm
201
+ package): `create-session.mjs`, `webhook-receiver.mjs`, and a `full-integration/` Express
202
+ store wiring session creation + webhook verification + idempotency.
203
+
204
+ ## Build
205
+
206
+ ```bash
207
+ pnpm build # tsup → dist/ (ESM + .d.ts)
208
+ ```
209
+
210
+ ## Migrating from 0.3.x → 0.5.0 (breaking)
211
+
212
+ - **Webhook verification now requires the timestamp** and enforces replay defense. Pass the
213
+ `X-Webhook-Timestamp` header as the 4th argument:
214
+ `verifyWebhookSignature(rawBody, sigHeader, secret, { timestamp: tsHeader })`. A 3-argument call now
215
+ returns `false`. The signed content changed from `body` to `` `${timestamp}.${body}` ``.
216
+ - Deduplicate on the signed `event.id`, not the `X-Webhook-Id` header.
217
+ - Removed 안1 methods (`payWithAuthorization`, `submitExternalPayment`) and escrow refund helpers —
218
+ checkout is non-custodial deposit-address (see Usage above).
219
+
220
+ ## License
221
+
222
+ MIT © BitPal. See [LICENSE](./LICENSE).