@blockmoblabs/cryptopay 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/README.md +117 -0
- package/dist/browser.d.ts +31 -0
- package/dist/browser.js +104 -0
- package/dist/index.d.ts +58 -0
- package/dist/index.js +123 -0
- package/dist/types.d.ts +123 -0
- package/dist/types.js +12 -0
- package/package.json +38 -0
package/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# @blockmoblabs/cryptopay
|
|
2
|
+
|
|
3
|
+
Take crypto payments in your product. Create a payment against your own customer, order or
|
|
4
|
+
subscription reference; CryptoPay handles the address, the chain and the confirmations, and posts you
|
|
5
|
+
a signed webhook carrying that reference back.
|
|
6
|
+
|
|
7
|
+
Zero dependencies. Node 18+ for the server entry; any modern browser for `@blockmoblabs/cryptopay/browser`.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @blockmoblabs/cryptopay
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Server
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { CryptoPay } from '@blockmoblabs/cryptopay';
|
|
17
|
+
|
|
18
|
+
const cryptopay = new CryptoPay({
|
|
19
|
+
apiKey: process.env.CRYPTOPAY_API_KEY!, // cp_test_… or cp_live_…
|
|
20
|
+
baseUrl: process.env.CRYPTOPAY_API_URL, // only when self-hosting
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const intent = await cryptopay.createIntent({
|
|
24
|
+
amountUsd: 40,
|
|
25
|
+
customerEmail: 'user@example.com',
|
|
26
|
+
customerRef: 'user_8812',
|
|
27
|
+
orderRef: 'ord_5571',
|
|
28
|
+
subscriptionRef: 'sub_221',
|
|
29
|
+
successUrl: 'https://yourapp.com/billing/done',
|
|
30
|
+
metadata: { plan: 'pro' },
|
|
31
|
+
idempotencyKey: 'ord_5571', // replaying returns the same intent
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The key decides the merchant **and** the mode — there is no mode flag to get wrong. `cryptopay.mode`
|
|
36
|
+
reports which one you are on.
|
|
37
|
+
|
|
38
|
+
| Method | What |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `createIntent(input)` | Create a payment. Returns `checkoutUrl` and `clientSecret`. |
|
|
41
|
+
| `getIntent(id)` | Read one payment, including its deposit sessions. |
|
|
42
|
+
| `listIntents(query)` | Page through payments; `search` matches any of your references. |
|
|
43
|
+
| `cancelIntent(id)` | Cancel an unpaid payment. Refused once completed. |
|
|
44
|
+
| `verifyWebhook(opts)` | Verify and parse a webhook. Throws on anything suspect. |
|
|
45
|
+
|
|
46
|
+
Non-2xx responses throw `CryptoPayError` with `status` and `body`.
|
|
47
|
+
|
|
48
|
+
## Browser
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { openCheckout, embedCheckout } from '@blockmoblabs/cryptopay/browser';
|
|
52
|
+
|
|
53
|
+
openCheckout({
|
|
54
|
+
checkoutUrl, // from the intent your server created
|
|
55
|
+
onStatus: (status) => console.log(status),
|
|
56
|
+
onSuccess: () => location.assign('/billing/done'),
|
|
57
|
+
onCancel: () => console.log('payer closed the modal'),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// or inline, into an element you already have
|
|
61
|
+
embedCheckout(document.getElementById('pay')!, { checkoutUrl, onSuccess: () => location.reload() });
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Nothing secret lives here: the checkout URL carries the payer's own credential, and the page it opens
|
|
65
|
+
is served by CryptoPay, so your page never handles an address or an amount. Only messages from the
|
|
66
|
+
checkout's origin can drive the modal.
|
|
67
|
+
|
|
68
|
+
## Webhooks
|
|
69
|
+
|
|
70
|
+
Pass the **raw** body — a re-serialized object will not match the signature.
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
app.post('/webhooks/cryptopay', express.raw({ type: 'application/json' }), async (req, res) => {
|
|
74
|
+
let event;
|
|
75
|
+
try {
|
|
76
|
+
event = cryptopay.verifyWebhook({
|
|
77
|
+
body: req.body, // Buffer, not parsed JSON
|
|
78
|
+
headers: req.headers,
|
|
79
|
+
secret: process.env.CRYPTOPAY_WEBHOOK_SECRET!,
|
|
80
|
+
});
|
|
81
|
+
} catch {
|
|
82
|
+
return res.sendStatus(400); // never act on an unverified body
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (await alreadyHandled(event.id)) return res.sendStatus(200);
|
|
86
|
+
|
|
87
|
+
if (event.type === 'payment.completed') {
|
|
88
|
+
await credit(event.customer.customerRef!, event.fee!.netUsd, event.customer.orderRef);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
await markHandled(event.id);
|
|
92
|
+
res.sendStatus(200); // 2xx stops the retries
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`verifyWebhook` checks the HMAC-SHA256 signature over `timestamp.eventId.rawBody` in constant time
|
|
97
|
+
and rejects a timestamp outside `toleranceMs` (default 5 minutes), so a validly signed replay of an
|
|
98
|
+
old event does not get through. Delivery is at-least-once: dedupe on `event.id`.
|
|
99
|
+
|
|
100
|
+
Events: `payment.created`, `payment.detected`, `payment.confirming`, `payment.underpaid`,
|
|
101
|
+
`payment.completed`, `payment.expired`, `payment.failed`.
|
|
102
|
+
|
|
103
|
+
## Test mode
|
|
104
|
+
|
|
105
|
+
A test key runs the whole lifecycle against a simulator — real intents, statuses and webhooks, no
|
|
106
|
+
chain and no money. The deposit address is `cptest_…`, deliberately invalid on every network, and the
|
|
107
|
+
payment confirms on a timer: detected within ~15s and confirmed within ~30s, since the watcher that
|
|
108
|
+
advances it ticks every 15 seconds. In an automated test, call `POST /sessions/:id/recheck` (what *I
|
|
109
|
+
have paid* does in the checkout) to confirm immediately instead of sleeping.
|
|
110
|
+
|
|
111
|
+
Going live is one environment variable: the key changes, the code does not.
|
|
112
|
+
|
|
113
|
+
## Fees
|
|
114
|
+
|
|
115
|
+
$2 flat per successful payment, charged once on completion. It comes off your side — the payer is
|
|
116
|
+
asked for exactly the invoice amount. On a $40 payment, `fee` is
|
|
117
|
+
`{ grossUsd: 40, feeUsd: 2, netUsd: 38 }`.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { PaymentStatus } from './types.js';
|
|
2
|
+
export type { PaymentStatus } from './types.js';
|
|
3
|
+
/** What the checkout page posts to its opener as the payment progresses. */
|
|
4
|
+
export type CheckoutMessage = {
|
|
5
|
+
source: 'cryptopay';
|
|
6
|
+
status: PaymentStatus | 'closed';
|
|
7
|
+
};
|
|
8
|
+
export type OpenCheckoutOptions = {
|
|
9
|
+
/** `checkoutUrl` from the intent your server created. */
|
|
10
|
+
checkoutUrl: string;
|
|
11
|
+
/** 'modal' overlays an iframe; 'redirect' navigates the whole page. Default 'modal'. */
|
|
12
|
+
mode?: 'modal' | 'redirect';
|
|
13
|
+
onStatus?: (status: PaymentStatus) => void;
|
|
14
|
+
onSuccess?: () => void;
|
|
15
|
+
/** Called when the payer closes the modal without completing. */
|
|
16
|
+
onCancel?: () => void;
|
|
17
|
+
};
|
|
18
|
+
export type CheckoutHandle = {
|
|
19
|
+
close: () => void;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Browser helper. Nothing secret lives here — the checkout URL already carries the payer's
|
|
23
|
+
* credential, and the page it opens is served by CryptoPay, so the merchant's own page never
|
|
24
|
+
* handles an address or an amount.
|
|
25
|
+
*
|
|
26
|
+
* import { openCheckout } from '@blockmoblabs/cryptopay/browser';
|
|
27
|
+
* openCheckout({ checkoutUrl, onSuccess: () => location.reload() });
|
|
28
|
+
*/
|
|
29
|
+
export declare function openCheckout(options: OpenCheckoutOptions): CheckoutHandle;
|
|
30
|
+
/** Inline variant: renders the checkout into an element you already have on the page. */
|
|
31
|
+
export declare function embedCheckout(target: HTMLElement, options: Omit<OpenCheckoutOptions, 'mode'>): CheckoutHandle;
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser helper. Nothing secret lives here — the checkout URL already carries the payer's
|
|
3
|
+
* credential, and the page it opens is served by CryptoPay, so the merchant's own page never
|
|
4
|
+
* handles an address or an amount.
|
|
5
|
+
*
|
|
6
|
+
* import { openCheckout } from '@blockmoblabs/cryptopay/browser';
|
|
7
|
+
* openCheckout({ checkoutUrl, onSuccess: () => location.reload() });
|
|
8
|
+
*/
|
|
9
|
+
export function openCheckout(options) {
|
|
10
|
+
if (options.mode === 'redirect') {
|
|
11
|
+
window.location.href = options.checkoutUrl;
|
|
12
|
+
return { close: () => undefined };
|
|
13
|
+
}
|
|
14
|
+
const origin = new URL(options.checkoutUrl, window.location.href).origin;
|
|
15
|
+
const overlay = document.createElement('div');
|
|
16
|
+
overlay.setAttribute('data-cryptopay', 'overlay');
|
|
17
|
+
overlay.setAttribute('role', 'dialog');
|
|
18
|
+
overlay.setAttribute('aria-modal', 'true');
|
|
19
|
+
overlay.setAttribute('aria-label', 'Pay with crypto');
|
|
20
|
+
overlay.style.cssText =
|
|
21
|
+
'position:fixed;inset:0;z-index:2147483000;display:flex;align-items:center;' +
|
|
22
|
+
'justify-content:center;background:rgba(8,10,16,.72);backdrop-filter:blur(4px);padding:16px';
|
|
23
|
+
const frame = document.createElement('iframe');
|
|
24
|
+
frame.src = options.checkoutUrl;
|
|
25
|
+
frame.title = 'CryptoPay checkout';
|
|
26
|
+
frame.allow = 'clipboard-write';
|
|
27
|
+
frame.style.cssText =
|
|
28
|
+
'width:100%;max-width:460px;height:min(760px,calc(100vh - 32px));border:0;' +
|
|
29
|
+
'border-radius:16px;background:#fff;box-shadow:0 24px 70px rgba(0,0,0,.45)';
|
|
30
|
+
const close = document.createElement('button');
|
|
31
|
+
close.type = 'button';
|
|
32
|
+
close.setAttribute('aria-label', 'Close checkout');
|
|
33
|
+
close.textContent = '×';
|
|
34
|
+
close.style.cssText =
|
|
35
|
+
'position:absolute;top:14px;right:16px;width:36px;height:36px;border:0;border-radius:50%;' +
|
|
36
|
+
'background:rgba(255,255,255,.14);color:#fff;font:22px/1 system-ui;cursor:pointer';
|
|
37
|
+
let done = false;
|
|
38
|
+
const teardown = (cancelled) => {
|
|
39
|
+
if (done)
|
|
40
|
+
return;
|
|
41
|
+
done = true;
|
|
42
|
+
window.removeEventListener('message', onMessage);
|
|
43
|
+
document.removeEventListener('keydown', onKey);
|
|
44
|
+
overlay.remove();
|
|
45
|
+
if (cancelled)
|
|
46
|
+
options.onCancel?.();
|
|
47
|
+
};
|
|
48
|
+
function onMessage(event) {
|
|
49
|
+
// Only the checkout origin may drive this modal.
|
|
50
|
+
if (event.origin !== origin)
|
|
51
|
+
return;
|
|
52
|
+
const data = event.data;
|
|
53
|
+
if (data?.source !== 'cryptopay')
|
|
54
|
+
return;
|
|
55
|
+
if (data.status === 'closed')
|
|
56
|
+
return teardown(true);
|
|
57
|
+
options.onStatus?.(data.status);
|
|
58
|
+
if (data.status === 'completed') {
|
|
59
|
+
teardown(false);
|
|
60
|
+
options.onSuccess?.();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function onKey(event) {
|
|
64
|
+
if (event.key === 'Escape')
|
|
65
|
+
teardown(true);
|
|
66
|
+
}
|
|
67
|
+
close.addEventListener('click', () => teardown(true));
|
|
68
|
+
overlay.addEventListener('click', (event) => {
|
|
69
|
+
if (event.target === overlay)
|
|
70
|
+
teardown(true);
|
|
71
|
+
});
|
|
72
|
+
window.addEventListener('message', onMessage);
|
|
73
|
+
document.addEventListener('keydown', onKey);
|
|
74
|
+
overlay.append(frame, close);
|
|
75
|
+
document.body.appendChild(overlay);
|
|
76
|
+
return { close: () => teardown(true) };
|
|
77
|
+
}
|
|
78
|
+
/** Inline variant: renders the checkout into an element you already have on the page. */
|
|
79
|
+
export function embedCheckout(target, options) {
|
|
80
|
+
const origin = new URL(options.checkoutUrl, window.location.href).origin;
|
|
81
|
+
const frame = document.createElement('iframe');
|
|
82
|
+
frame.src = options.checkoutUrl;
|
|
83
|
+
frame.title = 'CryptoPay checkout';
|
|
84
|
+
frame.allow = 'clipboard-write';
|
|
85
|
+
frame.style.cssText = 'width:100%;height:100%;min-height:620px;border:0;border-radius:12px';
|
|
86
|
+
const onMessage = (event) => {
|
|
87
|
+
if (event.origin !== origin)
|
|
88
|
+
return;
|
|
89
|
+
const data = event.data;
|
|
90
|
+
if (data?.source !== 'cryptopay' || data.status === 'closed')
|
|
91
|
+
return;
|
|
92
|
+
options.onStatus?.(data.status);
|
|
93
|
+
if (data.status === 'completed')
|
|
94
|
+
options.onSuccess?.();
|
|
95
|
+
};
|
|
96
|
+
window.addEventListener('message', onMessage);
|
|
97
|
+
target.appendChild(frame);
|
|
98
|
+
return {
|
|
99
|
+
close: () => {
|
|
100
|
+
window.removeEventListener('message', onMessage);
|
|
101
|
+
frame.remove();
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type CreateIntentInput, type PaymentIntent, type WebhookEvent } from './types.js';
|
|
2
|
+
export * from './types.js';
|
|
3
|
+
export type CryptoPayOptions = {
|
|
4
|
+
/** `cp_test_…` or `cp_live_…`. The key decides the mode; there is no mode flag to get wrong. */
|
|
5
|
+
apiKey: string;
|
|
6
|
+
/** Defaults to the hosted API. Point this at your own deployment if you self-host. */
|
|
7
|
+
baseUrl?: string;
|
|
8
|
+
/** Per-request timeout, ms. Default 20s. */
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
fetch?: typeof globalThis.fetch;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Server-side CryptoPay client. Keep the API key on your server: it can create charges.
|
|
14
|
+
*
|
|
15
|
+
* const cryptopay = new CryptoPay({ apiKey: process.env.CRYPTOPAY_API_KEY! });
|
|
16
|
+
* const intent = await cryptopay.createIntent({ amountUsd: 40, customerEmail: 'user@example.com' });
|
|
17
|
+
* // → hand intent.checkoutUrl to the browser
|
|
18
|
+
*/
|
|
19
|
+
export declare class CryptoPay {
|
|
20
|
+
private readonly apiKey;
|
|
21
|
+
private readonly baseUrl;
|
|
22
|
+
private readonly timeoutMs;
|
|
23
|
+
private readonly fetchImpl;
|
|
24
|
+
constructor(options: CryptoPayOptions);
|
|
25
|
+
/** `test` or `live`, read off the key. */
|
|
26
|
+
get mode(): 'test' | 'live';
|
|
27
|
+
/**
|
|
28
|
+
* Create a payment to collect. Pass whichever of your own identifiers you will want back:
|
|
29
|
+
* they are stored with the payment and echoed in every webhook.
|
|
30
|
+
*/
|
|
31
|
+
createIntent(input: CreateIntentInput): Promise<PaymentIntent>;
|
|
32
|
+
getIntent(intentId: string): Promise<PaymentIntent>;
|
|
33
|
+
cancelIntent(intentId: string): Promise<PaymentIntent>;
|
|
34
|
+
/** Page through your payments. Filter by any of your own references with `search`. */
|
|
35
|
+
listIntents(query?: {
|
|
36
|
+
status?: 'open' | 'completed';
|
|
37
|
+
search?: string;
|
|
38
|
+
cursor?: string;
|
|
39
|
+
limit?: number;
|
|
40
|
+
}): Promise<{
|
|
41
|
+
intents: PaymentIntent[];
|
|
42
|
+
nextCursor: string | null;
|
|
43
|
+
}>;
|
|
44
|
+
/**
|
|
45
|
+
* Verify a webhook and return its parsed body. Pass the RAW request body — a re-serialized object
|
|
46
|
+
* will not match the signature. Throws if the signature, the headers, or the timestamp is wrong,
|
|
47
|
+
* so a handler that calls this can trust whatever it returns.
|
|
48
|
+
*/
|
|
49
|
+
verifyWebhook(opts: {
|
|
50
|
+
body: string | Buffer;
|
|
51
|
+
/** The request headers. Case-insensitive lookup; a Node `IncomingHttpHeaders` works directly. */
|
|
52
|
+
headers: Record<string, string | string[] | undefined> | Headers;
|
|
53
|
+
secret: string;
|
|
54
|
+
/** How far out of date a signed request may be. Default 5 minutes. */
|
|
55
|
+
toleranceMs?: number;
|
|
56
|
+
}): WebhookEvent;
|
|
57
|
+
private request;
|
|
58
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { CryptoPayError, } from './types.js';
|
|
3
|
+
export * from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Server-side CryptoPay client. Keep the API key on your server: it can create charges.
|
|
6
|
+
*
|
|
7
|
+
* const cryptopay = new CryptoPay({ apiKey: process.env.CRYPTOPAY_API_KEY! });
|
|
8
|
+
* const intent = await cryptopay.createIntent({ amountUsd: 40, customerEmail: 'user@example.com' });
|
|
9
|
+
* // → hand intent.checkoutUrl to the browser
|
|
10
|
+
*/
|
|
11
|
+
export class CryptoPay {
|
|
12
|
+
apiKey;
|
|
13
|
+
baseUrl;
|
|
14
|
+
timeoutMs;
|
|
15
|
+
fetchImpl;
|
|
16
|
+
constructor(options) {
|
|
17
|
+
if (!options.apiKey)
|
|
18
|
+
throw new Error('CryptoPay: apiKey is required');
|
|
19
|
+
this.apiKey = options.apiKey;
|
|
20
|
+
this.baseUrl = (options.baseUrl ?? 'https://api.cryptopay.blockmob.io').replace(/\/+$/, '');
|
|
21
|
+
this.timeoutMs = options.timeoutMs ?? 20_000;
|
|
22
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
23
|
+
if (!this.fetchImpl)
|
|
24
|
+
throw new Error('CryptoPay: no fetch available — pass options.fetch');
|
|
25
|
+
}
|
|
26
|
+
/** `test` or `live`, read off the key. */
|
|
27
|
+
get mode() {
|
|
28
|
+
return this.apiKey.startsWith('cp_live_') ? 'live' : 'test';
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Create a payment to collect. Pass whichever of your own identifiers you will want back:
|
|
32
|
+
* they are stored with the payment and echoed in every webhook.
|
|
33
|
+
*/
|
|
34
|
+
createIntent(input) {
|
|
35
|
+
const { idempotencyKey, ...body } = input;
|
|
36
|
+
return this.request('POST', '/payment-intents', {
|
|
37
|
+
body,
|
|
38
|
+
headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
getIntent(intentId) {
|
|
42
|
+
return this.request('GET', `/payment-intents/${encodeURIComponent(intentId)}`);
|
|
43
|
+
}
|
|
44
|
+
cancelIntent(intentId) {
|
|
45
|
+
return this.request('POST', `/payment-intents/${encodeURIComponent(intentId)}/cancel`);
|
|
46
|
+
}
|
|
47
|
+
/** Page through your payments. Filter by any of your own references with `search`. */
|
|
48
|
+
listIntents(query = {}) {
|
|
49
|
+
const params = new URLSearchParams();
|
|
50
|
+
for (const [key, value] of Object.entries(query))
|
|
51
|
+
if (value !== undefined)
|
|
52
|
+
params.set(key, String(value));
|
|
53
|
+
const suffix = params.toString();
|
|
54
|
+
return this.request('GET', `/payment-intents${suffix ? `?${suffix}` : ''}`);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Verify a webhook and return its parsed body. Pass the RAW request body — a re-serialized object
|
|
58
|
+
* will not match the signature. Throws if the signature, the headers, or the timestamp is wrong,
|
|
59
|
+
* so a handler that calls this can trust whatever it returns.
|
|
60
|
+
*/
|
|
61
|
+
verifyWebhook(opts) {
|
|
62
|
+
const header = (name) => {
|
|
63
|
+
const raw = opts.headers instanceof Headers
|
|
64
|
+
? opts.headers.get(name)
|
|
65
|
+
: (opts.headers[name] ?? opts.headers[name.toLowerCase()]);
|
|
66
|
+
return Array.isArray(raw) ? (raw[0] ?? '') : (raw ?? '');
|
|
67
|
+
};
|
|
68
|
+
const signature = header('x-cryptopay-signature');
|
|
69
|
+
const timestamp = header('x-cryptopay-timestamp');
|
|
70
|
+
const eventId = header('x-cryptopay-event-id');
|
|
71
|
+
if (!signature || !timestamp || !eventId)
|
|
72
|
+
throw new Error('CryptoPay webhook: missing signature headers');
|
|
73
|
+
const age = Date.now() - Number(timestamp);
|
|
74
|
+
if (!Number.isFinite(age) || Math.abs(age) > (opts.toleranceMs ?? 5 * 60_000))
|
|
75
|
+
throw new Error('CryptoPay webhook: timestamp outside tolerance (possible replay)');
|
|
76
|
+
const body = typeof opts.body === 'string' ? opts.body : opts.body.toString('utf8');
|
|
77
|
+
const expected = Buffer.from(createHmac('sha256', opts.secret).update(`${timestamp}.${eventId}.${body}`).digest('hex'));
|
|
78
|
+
const given = Buffer.from(signature.replace(/^sha256=/, ''));
|
|
79
|
+
if (expected.length !== given.length || !timingSafeEqual(expected, given))
|
|
80
|
+
throw new Error('CryptoPay webhook: signature mismatch');
|
|
81
|
+
return JSON.parse(body);
|
|
82
|
+
}
|
|
83
|
+
async request(method, path, opts = {}) {
|
|
84
|
+
const controller = new AbortController();
|
|
85
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
86
|
+
try {
|
|
87
|
+
const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
88
|
+
method,
|
|
89
|
+
signal: controller.signal,
|
|
90
|
+
headers: {
|
|
91
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
92
|
+
...(opts.body ? { 'Content-Type': 'application/json' } : {}),
|
|
93
|
+
...opts.headers,
|
|
94
|
+
},
|
|
95
|
+
...(opts.body ? { body: JSON.stringify(opts.body) } : {}),
|
|
96
|
+
});
|
|
97
|
+
const text = await res.text();
|
|
98
|
+
const parsed = text ? safeJson(text) : null;
|
|
99
|
+
if (!res.ok)
|
|
100
|
+
throw new CryptoPayError(messageOf(parsed) ?? `HTTP ${res.status}`, res.status, parsed);
|
|
101
|
+
return parsed;
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function safeJson(text) {
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(text);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return text;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function messageOf(body) {
|
|
117
|
+
if (typeof body !== 'object' || body === null)
|
|
118
|
+
return null;
|
|
119
|
+
const message = body.message;
|
|
120
|
+
if (Array.isArray(message))
|
|
121
|
+
return message.join('; ');
|
|
122
|
+
return typeof message === 'string' ? message : null;
|
|
123
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** Shared shapes. Mirrors the API's own types; no runtime dependency on the server package. */
|
|
2
|
+
export type ApiMode = 'test' | 'live';
|
|
3
|
+
export type Chain = 'ethereum' | 'bsc' | 'polygon' | 'arbitrum' | 'base' | 'tron' | 'solana' | 'bitcoin';
|
|
4
|
+
export type Asset = 'USDT' | 'USDC' | 'BTC';
|
|
5
|
+
/** The lifecycle of a payment intent. */
|
|
6
|
+
export type PaymentStatus = 'created' | 'awaiting_payment' | 'payment_detected' | 'confirming' | 'completed' | 'expired' | 'failed';
|
|
7
|
+
export type PaymentIntent = {
|
|
8
|
+
intentId: string;
|
|
9
|
+
mode: ApiMode;
|
|
10
|
+
amountUsd: number;
|
|
11
|
+
status: PaymentStatus;
|
|
12
|
+
/** Your own identifiers, returned exactly as you sent them. */
|
|
13
|
+
reference: string | null;
|
|
14
|
+
customerEmail: string | null;
|
|
15
|
+
customerRef: string | null;
|
|
16
|
+
orderRef: string | null;
|
|
17
|
+
subscriptionRef: string | null;
|
|
18
|
+
metadata: unknown;
|
|
19
|
+
/** Present on a server-to-server response. Hand it to the browser; it is the payer's credential. */
|
|
20
|
+
clientSecret: string | null;
|
|
21
|
+
/** Redirect or iframe the payer here. Already carries the client secret. */
|
|
22
|
+
checkoutUrl: string | null;
|
|
23
|
+
successUrl: string | null;
|
|
24
|
+
cancelUrl: string | null;
|
|
25
|
+
/** Null until the payment completes. `netUsd` is what you are credited. */
|
|
26
|
+
fee: {
|
|
27
|
+
grossUsd: number;
|
|
28
|
+
feeUsd: number;
|
|
29
|
+
netUsd: number;
|
|
30
|
+
} | null;
|
|
31
|
+
settledAt: string | null;
|
|
32
|
+
completedAt: string | null;
|
|
33
|
+
expiresAt: string | null;
|
|
34
|
+
createdAt: string;
|
|
35
|
+
};
|
|
36
|
+
export type PaymentSession = {
|
|
37
|
+
sessionId: string;
|
|
38
|
+
intentId: string;
|
|
39
|
+
mode: ApiMode;
|
|
40
|
+
chain: Chain;
|
|
41
|
+
chainLabel: string;
|
|
42
|
+
standard: string;
|
|
43
|
+
asset: Asset;
|
|
44
|
+
address: string;
|
|
45
|
+
amountUsd: number;
|
|
46
|
+
amount: string;
|
|
47
|
+
received: string;
|
|
48
|
+
remaining: string;
|
|
49
|
+
status: 'awaiting' | 'underpaid' | 'detected' | 'confirmed' | 'expired';
|
|
50
|
+
confirmations: {
|
|
51
|
+
required: number;
|
|
52
|
+
current: number;
|
|
53
|
+
} | null;
|
|
54
|
+
finalitySeconds: number;
|
|
55
|
+
txHashes: string[];
|
|
56
|
+
explorerTx: string;
|
|
57
|
+
explorerAddress: string;
|
|
58
|
+
uri: string;
|
|
59
|
+
feeHint: string;
|
|
60
|
+
refundAddress: string | null;
|
|
61
|
+
expiresAt: string;
|
|
62
|
+
createdAt: string;
|
|
63
|
+
};
|
|
64
|
+
export type CreateIntentInput = {
|
|
65
|
+
amountUsd: number;
|
|
66
|
+
/** At least one of these is strongly recommended: it is how you recognise the payment later. */
|
|
67
|
+
customerEmail?: string;
|
|
68
|
+
customerRef?: string;
|
|
69
|
+
orderRef?: string;
|
|
70
|
+
subscriptionRef?: string;
|
|
71
|
+
reference?: string;
|
|
72
|
+
successUrl?: string;
|
|
73
|
+
cancelUrl?: string;
|
|
74
|
+
metadata?: Record<string, unknown>;
|
|
75
|
+
expiresInHours?: number;
|
|
76
|
+
/** Repeating a request with the same key returns the original intent instead of charging twice. */
|
|
77
|
+
idempotencyKey?: string;
|
|
78
|
+
};
|
|
79
|
+
export type WebhookEventType = 'payment.created' | 'payment.detected' | 'payment.confirming' | 'payment.underpaid' | 'payment.completed' | 'payment.expired' | 'payment.failed' | 'sweep.failed';
|
|
80
|
+
export type WebhookEvent = {
|
|
81
|
+
id: string;
|
|
82
|
+
type: WebhookEventType;
|
|
83
|
+
mode: ApiMode;
|
|
84
|
+
createdAt: string;
|
|
85
|
+
intentId: string;
|
|
86
|
+
status: PaymentStatus;
|
|
87
|
+
amountUsd: number;
|
|
88
|
+
reference: string | null;
|
|
89
|
+
/** Your identifiers, so you can map the payment to a user without another API call. */
|
|
90
|
+
customer: {
|
|
91
|
+
email: string | null;
|
|
92
|
+
customerRef: string | null;
|
|
93
|
+
orderRef: string | null;
|
|
94
|
+
subscriptionRef: string | null;
|
|
95
|
+
};
|
|
96
|
+
metadata: unknown;
|
|
97
|
+
fee: {
|
|
98
|
+
grossUsd: number;
|
|
99
|
+
feeUsd: number;
|
|
100
|
+
netUsd: number;
|
|
101
|
+
} | null;
|
|
102
|
+
payment: {
|
|
103
|
+
sessionId: string;
|
|
104
|
+
chain: Chain;
|
|
105
|
+
asset: Asset;
|
|
106
|
+
address: string;
|
|
107
|
+
status: string;
|
|
108
|
+
amount: string;
|
|
109
|
+
received: string;
|
|
110
|
+
/** What actually arrived, in dollars. This is the credited figure, not `amountUsd`. */
|
|
111
|
+
creditedUsd: number | null;
|
|
112
|
+
txHashes: string[];
|
|
113
|
+
confirmations: number | null;
|
|
114
|
+
refundAddress: string | null;
|
|
115
|
+
confirmedAt: string | null;
|
|
116
|
+
} | null;
|
|
117
|
+
};
|
|
118
|
+
/** Thrown for any non-2xx API response. */
|
|
119
|
+
export declare class CryptoPayError extends Error {
|
|
120
|
+
readonly status: number;
|
|
121
|
+
readonly body: unknown;
|
|
122
|
+
constructor(message: string, status: number, body: unknown);
|
|
123
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Shared shapes. Mirrors the API's own types; no runtime dependency on the server package. */
|
|
2
|
+
/** Thrown for any non-2xx API response. */
|
|
3
|
+
export class CryptoPayError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
body;
|
|
6
|
+
constructor(message, status, body) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.body = body;
|
|
10
|
+
this.name = 'CryptoPayError';
|
|
11
|
+
}
|
|
12
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@blockmoblabs/cryptopay",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CryptoPay crypto payment infrastructure: create payment intents, open the checkout, verify webhooks.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./browser": {
|
|
15
|
+
"types": "./dist/browser.d.ts",
|
|
16
|
+
"import": "./dist/browser.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -p tsconfig.json",
|
|
25
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^22.10.2",
|
|
29
|
+
"typescript": "^5.7.2"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18"
|
|
37
|
+
}
|
|
38
|
+
}
|