velirapay 0.1.0 → 0.2.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.
Files changed (117) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +699 -51
  3. package/dist/cjs/client.d.ts +1 -1
  4. package/dist/cjs/enums.d.ts +159 -27
  5. package/dist/cjs/enums.js +150 -25
  6. package/dist/cjs/errors.d.ts +10 -1
  7. package/dist/cjs/errors.js +12 -1
  8. package/dist/cjs/http/transport.js +18 -3
  9. package/dist/cjs/index.d.ts +4 -4
  10. package/dist/cjs/index.js +12 -2
  11. package/dist/cjs/page.d.ts +14 -2
  12. package/dist/cjs/page.js +19 -2
  13. package/dist/cjs/params.d.ts +17 -10
  14. package/dist/cjs/resources/account.d.ts +13 -13
  15. package/dist/cjs/resources/account.js +7 -7
  16. package/dist/cjs/resources/activity.d.ts +4 -3
  17. package/dist/cjs/resources/activity.js +1 -1
  18. package/dist/cjs/resources/api-key.d.ts +3 -2
  19. package/dist/cjs/resources/api-resource.d.ts +2 -0
  20. package/dist/cjs/resources/api-resource.js +5 -0
  21. package/dist/cjs/resources/charge.d.ts +10 -2
  22. package/dist/cjs/resources/charge.js +6 -0
  23. package/dist/cjs/resources/coupon.d.ts +2 -1
  24. package/dist/cjs/resources/event.d.ts +3 -3
  25. package/dist/cjs/resources/event.js +1 -1
  26. package/dist/cjs/resources/invoice.d.ts +4 -4
  27. package/dist/cjs/resources/member.d.ts +5 -4
  28. package/dist/cjs/resources/member.js +2 -2
  29. package/dist/cjs/resources/overview.d.ts +116 -4
  30. package/dist/cjs/resources/overview.js +76 -3
  31. package/dist/cjs/resources/payment-link.d.ts +3 -3
  32. package/dist/cjs/resources/payout.d.ts +5 -5
  33. package/dist/cjs/resources/payout.js +1 -1
  34. package/dist/cjs/resources/pro-payment.d.ts +3 -2
  35. package/dist/cjs/resources/supported-asset.d.ts +2 -2
  36. package/dist/cjs/resources/webhook-endpoint.d.ts +5 -4
  37. package/dist/cjs/resources/webhook-endpoint.js +3 -2
  38. package/dist/cjs/services/account.js +1 -1
  39. package/dist/cjs/services/api-keys.d.ts +7 -1
  40. package/dist/cjs/services/api-keys.js +7 -1
  41. package/dist/cjs/services/assets.d.ts +3 -4
  42. package/dist/cjs/services/assets.js +3 -2
  43. package/dist/cjs/services/charges.d.ts +10 -10
  44. package/dist/cjs/services/charges.js +12 -8
  45. package/dist/cjs/services/coupons.js +1 -1
  46. package/dist/cjs/services/invoices.js +5 -1
  47. package/dist/cjs/services/members.d.ts +2 -2
  48. package/dist/cjs/services/members.js +2 -2
  49. package/dist/cjs/services/overview.d.ts +1 -1
  50. package/dist/cjs/services/overview.js +3 -2
  51. package/dist/cjs/services/payment-links.js +4 -2
  52. package/dist/cjs/services/payout-addresses.d.ts +9 -4
  53. package/dist/cjs/services/payout-addresses.js +6 -2
  54. package/dist/cjs/services/service.d.ts +8 -3
  55. package/dist/cjs/services/service.js +45 -10
  56. package/dist/cjs/services/webhook-endpoints.d.ts +15 -2
  57. package/dist/cjs/services/webhook-endpoints.js +15 -2
  58. package/dist/cjs/version.d.ts +1 -1
  59. package/dist/cjs/version.js +1 -1
  60. package/dist/esm/client.d.ts +1 -1
  61. package/dist/esm/enums.d.ts +159 -27
  62. package/dist/esm/enums.js +149 -24
  63. package/dist/esm/errors.d.ts +10 -1
  64. package/dist/esm/errors.js +12 -1
  65. package/dist/esm/http/transport.js +18 -3
  66. package/dist/esm/index.d.ts +4 -4
  67. package/dist/esm/index.js +3 -3
  68. package/dist/esm/page.d.ts +14 -2
  69. package/dist/esm/page.js +17 -1
  70. package/dist/esm/params.d.ts +17 -10
  71. package/dist/esm/resources/account.d.ts +13 -13
  72. package/dist/esm/resources/account.js +8 -8
  73. package/dist/esm/resources/activity.d.ts +4 -3
  74. package/dist/esm/resources/activity.js +1 -1
  75. package/dist/esm/resources/api-key.d.ts +3 -2
  76. package/dist/esm/resources/api-resource.d.ts +2 -0
  77. package/dist/esm/resources/api-resource.js +4 -0
  78. package/dist/esm/resources/charge.d.ts +10 -2
  79. package/dist/esm/resources/charge.js +6 -0
  80. package/dist/esm/resources/coupon.d.ts +2 -1
  81. package/dist/esm/resources/event.d.ts +3 -3
  82. package/dist/esm/resources/event.js +1 -1
  83. package/dist/esm/resources/invoice.d.ts +4 -4
  84. package/dist/esm/resources/member.d.ts +5 -4
  85. package/dist/esm/resources/member.js +2 -2
  86. package/dist/esm/resources/overview.d.ts +116 -4
  87. package/dist/esm/resources/overview.js +77 -4
  88. package/dist/esm/resources/payment-link.d.ts +3 -3
  89. package/dist/esm/resources/payout.d.ts +5 -5
  90. package/dist/esm/resources/payout.js +1 -1
  91. package/dist/esm/resources/pro-payment.d.ts +3 -2
  92. package/dist/esm/resources/supported-asset.d.ts +2 -2
  93. package/dist/esm/resources/webhook-endpoint.d.ts +5 -4
  94. package/dist/esm/resources/webhook-endpoint.js +3 -2
  95. package/dist/esm/services/account.js +2 -2
  96. package/dist/esm/services/api-keys.d.ts +7 -1
  97. package/dist/esm/services/api-keys.js +7 -1
  98. package/dist/esm/services/assets.d.ts +3 -4
  99. package/dist/esm/services/assets.js +3 -2
  100. package/dist/esm/services/charges.d.ts +10 -10
  101. package/dist/esm/services/charges.js +13 -9
  102. package/dist/esm/services/coupons.js +2 -2
  103. package/dist/esm/services/invoices.js +6 -2
  104. package/dist/esm/services/members.d.ts +2 -2
  105. package/dist/esm/services/members.js +2 -2
  106. package/dist/esm/services/overview.d.ts +1 -1
  107. package/dist/esm/services/overview.js +4 -3
  108. package/dist/esm/services/payment-links.js +5 -3
  109. package/dist/esm/services/payout-addresses.d.ts +9 -4
  110. package/dist/esm/services/payout-addresses.js +6 -2
  111. package/dist/esm/services/service.d.ts +8 -3
  112. package/dist/esm/services/service.js +44 -10
  113. package/dist/esm/services/webhook-endpoints.d.ts +15 -2
  114. package/dist/esm/services/webhook-endpoints.js +15 -2
  115. package/dist/esm/version.d.ts +1 -1
  116. package/dist/esm/version.js +1 -1
  117. package/package.json +12 -2
package/README.md CHANGED
@@ -4,19 +4,34 @@
4
4
  [![Latest version](https://img.shields.io/npm/v/velirapay)](https://www.npmjs.com/package/velirapay)
5
5
  [![License](https://img.shields.io/github/license/VeliraPay/velirapay-node)](LICENSE)
6
6
 
7
- The official Node.js library for [VeliraPay](https://velirapay.com), the crypto payment processor. It covers the v1 API (the coins on offer, charges, invoices, payment links, coupons, payouts, events, webhook endpoints and your account) and verifies the webhooks VeliraPay sends you. It is written in TypeScript, ships its type definitions and has no dependencies.
7
+ The official Node.js library for [VeliraPay](https://velirapay.com), the crypto payment processor. It covers the whole v1 API, the coins on offer, charges, payment links, invoices, coupons, payouts and payout addresses, events, webhook endpoints, your account with its overview and activity log, API keys, members and invitations, and paying for Pro, and verifies the webhooks VeliraPay sends you. It is written in TypeScript, ships its type definitions and has no dependencies.
8
8
 
9
9
  - [Requirements](#requirements)
10
10
  - [Installation](#installation)
11
- - [Charging a customer](#charging-a-customer)
12
- - [Verifying webhooks](#verifying-webhooks)
11
+ - [Getting started](#getting-started)
13
12
  - [Which one to use](#which-one-to-use)
13
+ - [Supported coins](#supported-coins)
14
+ - [Charges](#charges)
15
+ - [Short and late payments](#short-and-late-payments)
16
+ - [Invoices](#invoices)
17
+ - [Payment links](#payment-links)
18
+ - [Coupons](#coupons)
19
+ - [Payouts](#payouts)
20
+ - [Events](#events)
21
+ - [Webhooks](#webhooks)
22
+ - [Webhook endpoints](#webhook-endpoints)
23
+ - [Your account](#your-account)
24
+ - [Members and invitations](#members-and-invitations)
25
+ - [API keys](#api-keys)
26
+ - [Pro](#pro)
14
27
  - [What the client covers](#what-the-client-covers)
15
28
  - [Lists and pagination](#lists-and-pagination)
16
29
  - [Errors](#errors)
17
30
  - [Retries and idempotency](#retries-and-idempotency)
31
+ - [Constants](#constants)
18
32
  - [Test mode](#test-mode)
19
33
  - [Configuration](#configuration)
34
+ - [Testing your integration](#testing-your-integration)
20
35
 
21
36
  The full API reference is at [velirapay.com/docs](https://velirapay.com/docs).
22
37
 
@@ -30,7 +45,7 @@ Node.js 22 or later. The package works with both `import` and `require`.
30
45
  npm install velirapay
31
46
  ```
32
47
 
33
- ## Charging a customer
48
+ ## Getting started
34
49
 
35
50
  Create an API key in the dashboard under **Developers → API keys**. Keep it on your server: anyone holding it can create charges and read your payments.
36
51
 
@@ -48,14 +63,14 @@ const charge = await velirapay.charges.create({
48
63
  customer: { email: 'ada@example.com', name: 'Ada Lovelace' },
49
64
  metadata: { order_id: '1042' },
50
65
  success_url: 'https://example.com/orders/1042',
51
- }, { idempotencyKey: 'order-1042' });
66
+ }, { idempotencyKey: 'order-1042-attempt-1' });
52
67
 
53
68
  res.redirect(303, charge.checkoutUrl);
54
69
  ```
55
70
 
56
- They pay there, and VeliraPay tells your server about it with a [webhook](#verifying-webhooks). The exchange rate is locked when the charge is created, and the customer has 30 minutes to pay.
71
+ They pay there, and VeliraPay tells your server about it with a [webhook](#webhooks). The exchange rate is locked when the charge is created, and the customer has 30 minutes to pay. The idempotency key makes the call safe to retry: use a new one for a new attempt, such as a charge replacing one that expired ([Retries and idempotency](#retries-and-idempotency)).
57
72
 
58
- Parameters are sent as the API names them, in snake_case, and objects come back with camelCase properties: `charge.checkoutUrl`, `charge.assetAmount`, `charge.customer.ipAddress`. Amounts are always strings, such as `'49.90'`, never floating-point numbers, so no precision is lost. `checkoutUrl` is typed `string | null` because the charges in webhooks leave it out; a charge the API answers with always has one.
73
+ Parameters are sent as the API names them, in snake_case, and objects come back with camelCase properties: `charge.checkoutUrl`, `charge.assetAmount`, `charge.customer.ipAddress`. Amounts are always strings, such as `'49.90'`, never floating-point numbers, so no precision is lost. A number you pass is rounded to 14 significant digits, as the API reads a JSON number, and sent as a decimal string, since the API refuses an exponent such as `5e-7`: `0.1 + 0.2` goes out as `'0.3'` and `0.00005` as `'0.00005'`. Pass a string to send every digit as it is. A charge the API answers with is an `ApiCharge`, whose `checkoutUrl` is always a string. Charges in webhooks leave it out, so theirs is `string | null`.
59
74
 
60
75
  With CommonJS:
61
76
 
@@ -63,15 +78,333 @@ With CommonJS:
63
78
  const { VeliraPayClient } = require('velirapay');
64
79
  ```
65
80
 
66
- A charge from your server comes from your server rather than the customer, so pass what you know about them as `customer` (`email`, `name`, `ip_address`, `user_agent`, `reference`, `phone`, `country` and `metadata`); it helps you spot fraud before you ship anything. `charge.customer` reads it back.
81
+ ## Which one to use
82
+
83
+ | When | Use | |
84
+ | --- | --- | --- |
85
+ | You are asking one customer for one payment | A [charge](#charges) | The default. It has one checkout page for that one payment, with the rate locked for 30 minutes. |
86
+ | The customer should choose the coin, or pays later | An [invoice](#invoices) | It needs their name and email, has no timer, can be itemised and reminded, and lets them pick the coin on its page. |
87
+ | Many customers pay through the same public page | A [payment link](#payment-links), created once | A product sold again and again, a tip jar, a link on your site. |
88
+
89
+ Do not create a new payment link for each customer or each order.
90
+
91
+ A charge needs its coin up front, because its exchange rate, amount and deposit address depend on it. Ask the customer on your own page, and offer them the coins from [`velirapay.assets.list()`](#supported-coins).
92
+
93
+ ## Supported coins
94
+
95
+ `assets.list()` gives the coins that can be paid in the key's mode, whether your account takes each, and the lowest price a charge in each can have.
96
+
97
+ ```js
98
+ const coins = await velirapay.assets.list({ currency: 'EUR' });
99
+
100
+ for (const coin of coins) {
101
+ coin.id; // "USDT": what to pass as asset, and coin.asset() is the Asset value
102
+ coin.name; // "Tether USD"
103
+ coin.network; // "Ethereum (ERC-20)", the network the customer has to send it on
104
+ coin.accepted; // whether your account takes it, which needs a payout address for that network
105
+ coin.minimumAmount; // "10", in the coin, or null when it has no minimum of its own
106
+ coin.minimumPrice; // "8.71", the lowest price in EUR right now
107
+ coin.exchangeRate; // "0.8709"
108
+ coin.confirmationWait; // "about a minute"
109
+ coin.networkSurcharge; // null, or what a charge in it asks on top of the price, in the coin
110
+ }
111
+
112
+ coins.currencies; // ["USD", "EUR", …], the currencies prices can be asked in
113
+ ```
114
+
115
+ Without `currency`, prices are in your account's default currency. The list is a single page.
67
116
 
68
- ## Verifying webhooks
117
+ No charge, payment link or invoice asks for less than the lowest price of its currency: 0.50 in USD, EUR, GBP and CHF, 0.70 CAD, 0.80 AUD and 25.00 TRY, below which a payment is mostly network fees. Some coins start higher: USDT and USDC on Ethereum start at 10 of the token, because paying one out costs enough in gas that a smaller payment would leave you little. `minimumPrice` is the figure to check for each coin. A coin's own minimum is in the coin, so its `minimumPrice` moves a little with the market: leave some room. Below it, `charges.create()` throws a `ValidationError` on `amount` or `asset`.
69
118
 
70
- Add an endpoint in the dashboard under **Developers → Webhooks** and copy its signing secret (`whsec_…`). `Webhook.constructEvent()` checks a delivery's signature and parses it. Pass it the **raw** body: a body parsed and encoded again no longer matches its signature. With Express:
119
+ A coin is only offered while VeliraPay can also pay it out, and a network can be switched off, so `asset` takes any [`Asset`](#constants) value but not every one can be paid in at any given time. `account.acceptedAssets` lists the coins your account takes right now ([Your account](#your-account)). The coins on BNB Chain, Base, Arbitrum One and Polygon are offered in live mode only.
120
+
121
+ ## Charges
122
+
123
+ A charge is how you ask a customer for a payment, in one coin. The exchange rate is locked when it is created, and the customer has 30 minutes to pay.
124
+
125
+ ```js
126
+ import { Asset, ChargeStatus } from 'velirapay';
127
+
128
+ // For an amount you set, with whatever you already know.
129
+ const charge = await velirapay.charges.create({
130
+ amount: '150.00',
131
+ currency: 'USD',
132
+ asset: Asset.USDC, // USD Coin on Ethereum
133
+ description: 'Pro plan, 1 year',
134
+ metadata: { order_id: '1042' },
135
+ customer: { email: 'ada@example.com', name: 'Ada Lovelace' },
136
+ custom_fields: [{ label: 'Company', value: 'Analytical Engines' }], // shown with the charge in the dashboard
137
+ success_url: 'https://example.com/orders/1042', // a button back once they have paid
138
+ cancel_url: 'https://example.com/cart', // a link back from the checkout
139
+ success_message: 'Thank you, your licence is on its way.', // on the page and in the receipt
140
+ send_email: true, // email them the payment page straight away
141
+ });
142
+
143
+ // For one of your payment links: the currency comes from the link, and so does the price when it is fixed.
144
+ const fromLink = await velirapay.charges.create({ payment_link: 'p4n8r2t6y1ua', asset: 'ETH' });
145
+
146
+ // With one of your coupons: fiatAmount is the lower price, and discountAmount what the coupon took off.
147
+ const discounted = await velirapay.charges.create({ payment_link: 'p4n8r2t6y1ua', asset: 'ETH', coupon: 'SPRING10' });
148
+ discounted.coupon; // "SPRING10"
149
+ discounted.discountAmount; // "15.00"
150
+
151
+ const retrieved = await velirapay.charges.retrieve('k3v9x2m7q8wz');
152
+
153
+ retrieved.status; // "pending", "underpaid", "paid", "expired" or "canceled"
154
+ retrieved.isPaid();
155
+ retrieved.assetAmount; // "0.0025", what the customer has to send
156
+ retrieved.receivedAmount; // what arrived so far
157
+ retrieved.depositAddress;
158
+ retrieved.transactions; // the transfers seen, as Transaction objects
159
+ retrieved.metadata; // { order_id: "1042" }
160
+
161
+ const paid = await velirapay.charges.list({ status: ChargeStatus.Paid });
162
+ const hers = await velirapay.charges.list({ search: 'ada@example.com', created_from: new Date('2026-10-01'), asset: Asset.BTC });
163
+
164
+ // Email the customer the payment page, if you did not pass send_email.
165
+ await velirapay.charges.send('k3v9x2m7q8wz');
166
+
167
+ // Only while the charge is pending and no transfer to it has been seen.
168
+ await velirapay.charges.cancel('k3v9x2m7q8wz');
169
+ ```
170
+
171
+ `send()` needs a customer email and only works while the charge is pending, or it throws a `ConflictError`. Creating a charge with `send_email` but no customer email throws a `ValidationError` on `customer_email` and creates nothing. A charge can be emailed once every 10 minutes, and the emails count towards the hourly limit under [Invoices](#invoices). In test mode, charge emails only go to members of your account.
172
+
173
+ `metadata` holds up to 20 keys of at most 40 characters each. Values are strings, numbers, booleans or `null`, and a string is at most 500 characters.
174
+
175
+ The accounts one person owns can open 300 charges between them in each mode within 30 minutes, through the API, the MCP server and the dashboard together, and 30 until one of them has been paid in live mode. Canceled charges count, paid live ones do not, and the payments customers start on the hosted checkout are not counted. Past that, `create()` throws a `RateLimitError` whose `retryAfter()` says how many seconds to wait.
176
+
177
+ ### Customer details
178
+
179
+ `charge.customer` holds what is known about who pays, to help you spot fraud before you ship anything. When the customer starts the payment on the hosted checkout, VeliraPay records the IP address and browser they did it from. A charge you create from your server comes from your server rather than the customer, so pass what you know:
180
+
181
+ ```js
182
+ const charge = await velirapay.charges.create({
183
+ amount: '150.00',
184
+ currency: 'USD',
185
+ asset: 'BTC',
186
+ customer: {
187
+ email: 'ada@example.com',
188
+ name: 'Ada Lovelace',
189
+ ip_address: req.ip, // the customer's address, not your server's
190
+ user_agent: req.get('user-agent'),
191
+ reference: 'cus_1042', // your own id for the customer
192
+ phone: '+44 20 7946 0958',
193
+ country: 'GB', // ISO 3166 two-letter code
194
+ metadata: { orders: 7 }, // the same limits as the charge's metadata
195
+ },
196
+ });
197
+
198
+ charge.customer.ipAddress; // "203.0.113.7"
199
+ charge.customer.userAgent;
200
+ charge.customer.country; // "GB"
201
+ charge.customer.metadata; // { orders: 7 }
202
+ ```
203
+
204
+ Every field is optional, and any other key is refused with a `ValidationError`. The older `customer_email` parameter still works.
205
+
206
+ ### Network surcharges
207
+
208
+ Passing a payment on costs the same whatever its size on some networks, so the customer pays that part: a charge in USDT on Tron (`Asset.USDT_TRON`) asks for 2.50 USDT on top of the price. The coin says so before it is chosen, as `coin.networkSurcharge` from `assets.list()`, so tell the customer; the checkout does. A charge counts it in `assetAmount` and gives it again as `charge.networkSurcharge`, which is `null` for every coin without one. VeliraPay keeps it: it is not part of your payout, and a refund can never include it.
209
+
210
+ ### Bitcoin over Lightning
211
+
212
+ A charge in `Asset.BTC_LIGHTNING` is paid with a Lightning invoice rather than to an address. `charge.paymentRequest` is the invoice (BOLT11), which the checkout shows as a QR code, and `charge.depositAddress` is its payment hash; `paymentRequest` is `null` for every other coin. A Lightning invoice is for one exact amount, and is paid once.
213
+
214
+ ## Short and late payments
215
+
216
+ A charge that received less than it asked for is `underpaid`. A transfer that arrives after the charge expired or was canceled, or tops up an underpaid charge after its rate lock ended, is a late payment, announced with the `charge.late_payment` webhook; a top-up is announced once it has confirmed. Neither counts until you accept it, at the old rate, for what arrived:
217
+
218
+ ```js
219
+ // Settle an underpaid charge, or a late payment on an expired or canceled one, as paid for what arrived and confirmed.
220
+ const accepted = await velirapay.charges.accept('k3v9x2m7q8wz');
221
+
222
+ // Record a refund you sent the customer from your own wallet, in the charge's coin.
223
+ await velirapay.charges.recordRefund('k3v9x2m7q8wz', {
224
+ amount: '0.0005',
225
+ txid: '7d1c5e9a…',
226
+ reason: 'Returned item',
227
+ });
228
+ ```
229
+
230
+ `accept()` throws a `ConflictError` while a payment to an underpaid charge is still confirming, and for a charge that is neither underpaid nor late. A late payment on top of a charge that is already paid is counted on its own. `recordRefund()` only records what you sent: the transfer itself is yours to make. An underpaid charge or a late payment has to be accepted before it can be refunded, or `recordRefund()` throws a `ValidationError` on `amount` saying so, and the network surcharge is never refundable.
231
+
232
+ ## Invoices
233
+
234
+ An invoice is a bill with a hosted page, where the customer pays in the coin of their choice. Use it for a bill to pay later, or when the customer should choose the coin.
235
+
236
+ ```js
237
+ const invoice = await velirapay.invoices.create({
238
+ customer_name: 'Grace Hopper',
239
+ customer_email: 'grace@example.com',
240
+ currency: 'USD',
241
+ items: [
242
+ { description: 'Consulting', quantity: '10', unit_amount: '100.00' },
243
+ { description: 'Hosting', quantity: '1', unit_amount: '250.00' },
244
+ ],
245
+ due_at: '2026-12-01', // a day, as YYYY-MM-DD
246
+ send_email: true,
247
+ });
248
+
249
+ invoice.number; // "INV-0042"
250
+ invoice.hostedUrl; // the page where the customer pays
251
+
252
+ await velirapay.invoices.send(invoice.id); // email it, or send a reminder
253
+ await velirapay.invoices.void(invoice.id);
254
+
255
+ const overdue = await velirapay.invoices.list({ overdue: true });
256
+ const hers = await velirapay.invoices.list({ search: 'grace@example.com' });
257
+ ```
258
+
259
+ Pass `amount` instead of `items` to bill a single total. The items may each cost less than the lowest price of the currency, as long as they add up to it.
260
+
261
+ An invoice can be emailed once every 10 minutes. An account can send 100 emails to its customers an hour, or 10 until one of its owner's accounts has been paid in live mode: invoices, charges emailed to their customers and refund notices together, shared with the other accounts its owner owns. Past either limit, `send()` throws a `RateLimitError` whose `retryAfter()` says how many seconds to wait, and `create()` with `send_email` throws it and creates nothing. In test mode, invoices are only emailed to members of your account: anyone else is refused with a `ValidationError` on `customer_email`, by `send()` as by `create()` with `send_email`.
262
+
263
+ ## Payment links
264
+
265
+ A payment link is one reusable public checkout page, for a fixed price or one the customer chooses, opened by many customers who each pick their own coin. Create it once and share it. To charge one customer, [create a charge](#charges) instead.
266
+
267
+ ```js
268
+ import { Asset, PaymentLinkStatus, PricingType } from 'velirapay';
269
+
270
+ const link = await velirapay.paymentLinks.create({
271
+ title: 'Pro plan',
272
+ pricing_type: PricingType.Fixed,
273
+ amount: '150.00',
274
+ currency: 'EUR',
275
+ accepted_assets: [Asset.BTC, Asset.ETH, Asset.USDC], // null accepts every coin the account takes
276
+ success_url: 'https://example.com/thanks',
277
+ custom_fields: [{ label: 'Company', required: false }],
278
+ });
279
+
280
+ link.checkoutUrl; // https://velirapay.com/pay/…
281
+
282
+ const tipJar = await velirapay.paymentLinks.create({
283
+ title: 'Tip jar',
284
+ pricing_type: PricingType.Open,
285
+ currency: 'USD',
286
+ suggested_amounts: ['5.00', '10.00', '25.00'],
287
+ min_amount: '1.00',
288
+ });
289
+
290
+ await velirapay.paymentLinks.update(link.id, { title: 'Pro plan (yearly)' });
291
+ await velirapay.paymentLinks.archive(link.id); // stops taking payments
292
+ await velirapay.paymentLinks.restore(link.id);
293
+
294
+ const existing = await velirapay.paymentLinks.list({ status: PaymentLinkStatus.Active, search: 'Pro plan' });
295
+ ```
296
+
297
+ ## Coupons
298
+
299
+ A coupon takes a percentage or a fixed amount off a payment. Customers type its code on the hosted checkout of a payment link, you add it to the link's URL so it is already applied (`https://velirapay.com/pay/p4n8r2t6y1ua?coupon=SPRING10`), or you pass it as `coupon` when you [create a charge](#charges). Invoices do not take coupons.
300
+
301
+ ```js
302
+ const coupon = await velirapay.coupons.create({
303
+ code: 'SPRING10', // 3 to 32 letters, digits, dashes or underscores, stored in capitals
304
+ percent_off: '10', // or amount_off: '5.00' with a currency
305
+ max_redemptions: 100, // an open charge holds a use until it is paid, expires or is canceled
306
+ once_per_customer: true, // the customer's email is then required to use it
307
+ payment_links: ['p4n8r2t6y1ua'], // empty for every link, and charges made without one
308
+ expires_at: '2026-12-31', // a bare date ends at 23:59:59 UTC
309
+ });
310
+
311
+ coupon.id; // "SPRING10": the code is the id, and is matched in any case
312
+ coupon.timesRedeemed; // how many paid charges used it
313
+
314
+ await velirapay.coupons.update('SPRING10', { active: false }); // pause it
315
+ await velirapay.coupons.delete('SPRING10'); // charges that used it keep its code and their discount
316
+
317
+ const found = await velirapay.coupons.list({ search: 'spring' });
318
+ ```
319
+
320
+ `currency` is required with `amount_off` or `min_amount`, and the coupon then only works on payments in that currency. A coupon cannot take a payment under the lowest price of its currency. Once a coupon exists, its code, discount, currency and `min_amount` cannot change: `update()` takes `max_redemptions`, `once_per_customer`, `payment_links` (which replaces the whole list), `active` and `expires_at`.
321
+
322
+ A coupon that cannot be used throws a `ValidationError` on `coupon` from `charges.create()`, saying why: it does not exist in this mode, is paused, has expired, is limited to other links, is for another currency, needs a higher price, would leave nothing to pay or is used up, or it is once per customer and the charge has no customer email or that customer already used it.
323
+
324
+ ## Payouts
325
+
326
+ Every paid charge has one payout: what came in, what was kept, what you are owed, what actually landed and the transaction that carried it. These only read: payouts go out on their own, and nothing here can raise, redirect or reverse one.
327
+
328
+ ```js
329
+ import { Asset, PayoutStatus } from 'velirapay';
330
+
331
+ const payout = await velirapay.payouts.retrieve('k3v9x2m7q8wz'); // by the charge's id; a NotFoundError until it is paid
332
+
333
+ payout.status; // "pending", "sending", "sent", "failed" or "manual"
334
+ payout.isSent(); // confirmed: the money is in your wallet
335
+ payout.grossAmount; // what the customer paid
336
+ payout.feeAmount; // what was kept
337
+ payout.netAmount; // what you are owed, before the chain's own fee
338
+ payout.networkFee; // what the chain charged
339
+ payout.sentAmount; // what therefore reached you
340
+ payout.payoutTxid; // the transaction, and payout.explorerUrl its page on a block explorer
341
+ payout.lastError; // why the last try did not work, or why it is held
342
+ payout.nextAttemptAt;
343
+
344
+ const failed = await velirapay.payouts.list({ status: PayoutStatus.Failed });
345
+ const tether = await velirapay.payouts.list({ asset: Asset.USDT, search: '0x9a4f' }); // a charge id or a transaction hash
346
+
347
+ for (const coin of await velirapay.payouts.summary()) {
348
+ coin.asset; // one row per coin you have been paid in
349
+ coin.paidOutAmount; // what has reached you
350
+ coin.inFlightAmount; // what is on its way
351
+ coin.owedAmount; // what has not left yet
352
+ coin.feeAmount;
353
+ coin.waitingForAddress; // how many payouts wait for you to give an address
354
+ }
355
+ ```
356
+
357
+ ### Payout addresses
358
+
359
+ Payouts go to one address per network family, which the [`AddressNetwork`](#constants) values name: `bitcoin`, `litecoin`, `dogecoin`, `bitcoincash`, `evm` (Ethereum, for ETH, USDT and USDC there), `bsc` (BNB Chain), `base`, `arbitrum`, `polygon`, `tron`, `monero`, `solana` and `lightning`, whose address is a Lightning address such as `name@example.com`. BNB Chain, Base, Arbitrum One and Polygon each have their own, so nobody is paid on a chain they did not choose.
360
+
361
+ ```js
362
+ import { AddressNetwork } from 'velirapay';
363
+
364
+ for (const address of await velirapay.payoutAddresses.list()) { // needs payout_addresses:read
365
+ address.network; // "bitcoin", "evm", …
366
+ address.name; // "Bitcoin"
367
+ address.address; // null while there is none: payouts in its coins wait for one
368
+ address.assets; // the coins on offer on that network
369
+ }
370
+
371
+ await velirapay.payoutAddresses.update(AddressNetwork.Bitcoin, { address: 'bc1q…' });
372
+ await velirapay.payoutAddresses.delete(AddressNetwork.Bitcoin);
373
+ ```
374
+
375
+ `list()` gives one entry per network with coins on offer in the key's mode. Changing one needs the sensitive `payout_addresses:write` permission and an owner switching on "Let API keys and AI apps change payout addresses" under Payment settings, which `account.apiPayoutAddressChanges` reads; until then `update()` and `delete()` throw a `PermissionError` saying so. Owners and admins are emailed about every change, and once the account has been paid out, payouts to a new address wait 48 hours, until `payoutsHeldUntil`.
376
+
377
+ ## Events
378
+
379
+ Every change to a charge or an invoice is recorded as an event, such as `charge.paid` or `invoice.viewed`. Webhooks carry the same events.
380
+
381
+ ```js
382
+ import { EventType } from 'velirapay';
383
+
384
+ const forCharge = await velirapay.events.list({ charge: 'k3v9x2m7q8wz' });
385
+ const detected = await velirapay.events.list({ type: EventType.ChargePaymentDetected });
386
+
387
+ const event = await velirapay.events.retrieve('9b1f7a3e-2c4d-4e8f-a6b0-1d2c3e4f5a6b');
388
+ event.is(EventType.ChargePaid);
389
+ event.details; // what was recorded, such as the amount and txid of a payment or a refund
390
+ event.charge; // the charge as it is now, without its timeline
391
+ ```
392
+
393
+ ## Webhooks
394
+
395
+ Add an endpoint in the dashboard under **Developers → Webhooks**, or [with the API](#webhook-endpoints), and keep its signing secret (`whsec_…`). Each delivery is a POST with a JSON body and these headers:
396
+
397
+ | Header | |
398
+ | --- | --- |
399
+ | `X-VeliraPay-Signature` (`Webhook.SIGNATURE_HEADER`) | `t={timestamp},v1={signature}` |
400
+ | `X-VeliraPay-Event` (`Webhook.EVENT_HEADER`) | The event type, such as `charge.paid` |
401
+ | `X-VeliraPay-Delivery` (`Webhook.DELIVERY_HEADER`) | The delivery's id |
402
+
403
+ `Webhook.constructEvent()` checks a delivery's signature and parses it. Pass it the **raw** body: a body parsed and encoded again no longer matches its signature. With Express:
71
404
 
72
405
  ```js
73
406
  import express from 'express';
74
- import { SignatureVerificationError, Webhook } from 'velirapay';
407
+ import { EventType, SignatureVerificationError, Webhook } from 'velirapay';
75
408
 
76
409
  const app = express();
77
410
 
@@ -93,14 +426,24 @@ app.post('/webhooks/velirapay', express.raw({ type: 'application/json' }), (req,
93
426
  return res.type('application/json').send(event.challengeResponse());
94
427
  }
95
428
 
429
+ // A sample from "Send test event", or a test-mode event: acknowledge it, and fulfil nothing for real.
430
+ if (event.test || !event.isLiveMode()) {
431
+ return res.sendStatus(200);
432
+ }
433
+
96
434
  // The same event can arrive more than once: remember this key, and skip the events you have handled.
97
435
  const key = event.eventId ?? event.id;
98
436
 
99
- if (event.is('charge.paid')) {
100
- // Fulfil the order event.charge.metadata.order_id.
437
+ if (event.is(EventType.ChargePaid) && event.charge !== null) {
438
+ // A charge made without metadata, such as one from a payment link, has no order_id.
439
+ const orderId = event.charge.metadata['order_id'];
440
+
441
+ if (typeof orderId === 'string') {
442
+ // Fulfil the order, once per key.
443
+ }
101
444
  }
102
445
 
103
- res.sendStatus(200);
446
+ return res.sendStatus(200);
104
447
  });
105
448
  ```
106
449
 
@@ -111,27 +454,247 @@ Some things to know:
111
454
  - **Answer the verification challenge.** When an endpoint is added, or its URL changes, VeliraPay sends it a signed `webhook.verification` delivery and sends it nothing else until it answers with HTTP 200 and the challenge, as `event.challengeResponse()` gives it. Endpoint URLs must use `https://`.
112
455
  - **A second signature for a day after a rotation.** For a day after you rotate the signing secret, the `X-VeliraPay-Signature` header carries a `v1` made with the old secret too, so a receiver that still has the old one keeps working. `constructEvent()` accepts a delivery whichever `v1` matches.
113
456
  - **Signatures expire.** A signature more than 5 minutes from the current time is rejected, which stops an intercepted delivery from being replayed. Change the limit with the `tolerance` argument, in seconds; `0` turns the age check off.
114
- - **Answer quickly.** A delivery counts as delivered when your endpoint answers with a 2xx status within 15 seconds, so queue slow work. Redirects are not followed.
115
- - **Failed deliveries are retried.** A delivery is tried 14 times over about 3 days.
116
- - **Skip duplicates by event.** A retried delivery keeps its id (`event.id`), but each endpoint gets its own copy of an event under its own id. `event.eventId` is the same on every copy, so `event.eventId ?? event.id` is the key to remember.
457
+ - **Answer quickly.** A delivery counts as delivered when your endpoint answers with a 2xx status within 15 seconds, so queue slow work. Redirects are not followed, so a 3xx counts as a failure: give the endpoint's final URL.
458
+ - **Failed deliveries are retried.** A delivery is tried 14 times over about 3 days: after 1 minute, 5 minutes, 30 minutes, 1 hour, 2 hours, 4 hours and 8 hours, then every 10 hours. When one runs out of tries, the account's owners and admins are emailed, at most once a day per endpoint, and an endpoint whose last 15 deliveries all failed is switched off until it is switched back on. While it is off it receives nothing, and switching it back on does not send what it missed; `webhookEndpoints.retryDelivery()` sends one again.
459
+ - **Skip duplicates by event.** A retried delivery keeps its id (`event.id`, also the `X-VeliraPay-Delivery` header), but each endpoint gets its own copy of an event under its own id. `event.eventId` is the same on every copy and is the event's id in [`events.retrieve()`](#events), so `event.eventId ?? event.id` is the key to remember.
117
460
  - **Fulfil once.** A paid invoice sends both `charge.paid`, for the charge that paid it, and `invoice.paid`. Fulfil on one of them.
118
- - **Deliveries can arrive out of order.** When the order matters, fetch the current state with `velirapay.charges.retrieve(event.charge.id)`.
461
+ - **Deliveries can arrive out of order.** When the order matters, fetch the current state, such as with `velirapay.charges.retrieve(event.charge.id)` once `event.charge` is not `null`, rather than trusting the payload.
462
+ - **Test deliveries.** The dashboard's "Send test event" button and `webhookEndpoints.test()` send a sample `charge.paid` with `event.test` set to `true` and no `eventId`. Its charge is a made-up sample without many of a real one's fields, such as `transactions` and `customer`. An endpoint added in test mode receives the test-mode events, whose `event.mode` is `"test"`.
119
463
 
120
- `charge.payment_detected` arrives as soon as a transfer is seen, before it is confirmed: tell the customer, but do not fulfil. It and `charge.late_payment` carry that transfer as `event.transaction`; `payout.sent` and `payout.confirmed` carry the payout as `event.payout`.
464
+ `charge.payment_detected` arrives as soon as a transfer to a charge is seen, before it is confirmed: tell the customer, but do not fulfil. `charge.late_payment` arrives when a transfer reaches a charge that already expired, was canceled or was paid, or, once it has confirmed, when one tops up an underpaid charge after its rate lock ended. A late payment to an expired, canceled or underpaid charge waits for you to [accept it](#short-and-late-payments). Both events carry that transfer as `event.transaction`; for every other event it is `null`.
121
465
 
122
- To test your handler, sign a payload yourself: `Webhook.signatureHeader(payload, 'whsec_test')` gives the header VeliraPay would send.
466
+ `payout.sent` arrives when your share of a paid charge has been broadcast, and `payout.confirmed` when the chain has confirmed it. Both carry the charge as `event.charge` and the payout as `event.payout`, the same object `payouts.retrieve()` answers with; for every other event it is `null`. Book the money on `payout.confirmed`: a broadcast payout can still be dropped and go out again, so `payout.sent` can arrive more than once for a charge.
123
467
 
124
- ## Which one to use
468
+ The charge and invoice in a delivery are not quite the ones the API answers with:
125
469
 
126
- | When | Use | |
127
- | --- | --- | --- |
128
- | You are asking one customer for one payment | A charge | The default. It has one checkout page for that one payment, with the rate locked for 30 minutes. |
129
- | The customer should choose the coin, or pays later | An invoice | It needs their name and email, has no timer, can be itemised and reminded, and lets them pick the coin on its page. |
130
- | Many customers pay through the same public page | A payment link, created once | A product sold again and again, a tip jar, a link on your site. |
470
+ - A charge's coin amounts and exchange rate carry all 18 decimal places (`"0.002500000000000000"` rather than `"0.0025"`). Compare amounts as numbers, not as strings.
471
+ - A charge has no `timeline`, `refunds`, `checkoutUrl`, `receiptUrl`, `successUrl`, `cancelUrl` or `successMessage`. It names its payment link with its title and its invoice with its number: `charge.paymentLink` and `charge.invoice` hold the codes, and `charge.paymentLinkTitle` and `charge.invoiceNumber` the rest, which are `null` on the API's charges.
472
+ - An invoice has no `overdue` or `documentUrl`; both are `null`.
473
+ - `charge.refunded` carries no refund. Read its amount, txid and reason from the event: `(await velirapay.events.retrieve(eventId)).details`.
131
474
 
132
- Do not create a new payment link for each customer or each order.
475
+ Fetch the object when you need what a delivery leaves out.
476
+
477
+ ## Webhook endpoints
478
+
479
+ List them to see from your code why deliveries are not arriving:
480
+
481
+ ```js
482
+ import { WebhookDeliveryStatus } from 'velirapay';
483
+
484
+ for (const endpoint of await velirapay.webhookEndpoints.list()) {
485
+ endpoint.url;
486
+ endpoint.events; // the event types it takes
487
+ endpoint.active;
488
+ endpoint.verified; // it receives nothing until it has answered the challenge
489
+ endpoint.isReceiving(); // both of those
490
+ endpoint.deliveriesCount; // of the last 30 days
491
+ endpoint.failedDeliveriesCount;
492
+ }
493
+
494
+ for (const failure of await velirapay.webhookEndpoints.deliveries('12', { status: WebhookDeliveryStatus.Failed })) {
495
+ failure.event; // "charge.paid"
496
+ failure.responseStatus; // what the endpoint last answered
497
+ failure.error;
498
+ }
499
+ ```
500
+
501
+ Retrieve one delivery to see every attempt to send it, the latest first, and the JSON body it carried:
502
+
503
+ ```js
504
+ const delivery = await velirapay.webhookEndpoints.retrieveDelivery('12', '9d2e0b8a-0000-4000-8000-5f3a00000000');
505
+
506
+ for (const attempt of delivery.attemptLog) {
507
+ attempt.requestHeaders; // the signatures cut out, the timestamp they signed kept
508
+ attempt.responseStatus; // null when nothing answered
509
+ attempt.responseHeaders; // a few dozen at most, never cookies
510
+ attempt.responseBody; // the first 2,000 characters
511
+ attempt.durationMs;
512
+ attempt.error; // such as a timeout
513
+ }
514
+
515
+ delivery.payload; // the JSON body, or null for a key without events:read
516
+ ```
517
+
518
+ Deliveries and their attempts are kept for 30 days.
519
+
520
+ A key with the sensitive `webhook_endpoints:write` permission ticked can also add and change them. A new endpoint is sent its verification challenge at once, and the answer that adds it, or rotates its secret, is the only one that carries the signing secret:
521
+
522
+ ```js
523
+ import { EventType } from 'velirapay';
524
+
525
+ const endpoint = await velirapay.webhookEndpoints.create({
526
+ url: 'https://example.com/webhooks/velirapay',
527
+ events: [EventType.ChargePaid, EventType.InvoicePaid],
528
+ });
529
+
530
+ endpoint.secret; // "whsec_…": store it now, it is never shown again
531
+ endpoint.verificationError; // null once the endpoint answered the challenge
532
+
533
+ await velirapay.webhookEndpoints.update(endpoint.id, { active: false });
534
+ await velirapay.webhookEndpoints.verify(endpoint.id); // send the challenge again
535
+ await velirapay.webhookEndpoints.test(endpoint.id); // a sample charge.paid, marked test
536
+
537
+ const rotated = await velirapay.webhookEndpoints.rotateSecret(endpoint.id);
538
+ rotated.secret; // the new one
539
+ rotated.previousSecretExpiresAt; // until when the old one signs too
540
+
541
+ await velirapay.webhookEndpoints.retryDelivery(endpoint.id, '9d2e0b8a-0000-4000-8000-5f3a00000000');
542
+ await velirapay.webhookEndpoints.delete(endpoint.id);
543
+ ```
544
+
545
+ An account can have 10 endpoints in each mode. Adding an endpoint, giving one a new `url`, `verify()`, `test()` and `retryDelivery()` each send the endpoint a request, so together they are limited to 10 a minute per key. Past that the API answers 429: a short wait is [retried](#retries-and-idempotency) for you, and a longer one throws a `RateLimitError` whose `retryAfter()` says how many seconds to wait.
546
+
547
+ **When the answer that carries a secret is lost.** The API never keeps the answers of `create()` and `rotateSecret()`, since they carry a secret it shows once. If one is lost on the way, a repeat with the same idempotency key, which is what the library's own [retry](#retries-and-idempotency) sends, throws a `ConflictError` whose `wasReplayed()` is `true`: the endpoint was added, or its secret rotated, but the secret cannot be shown again.
548
+
549
+ - After `create()`, find the endpoint with `list()` and call `rotateSecret()`, since nothing relies on its first secret yet, or `delete()` it and add it again.
550
+ - After `rotateSecret()`, the secret your receiver holds keeps verifying for a day, as the previous one. Rotating again makes the lost secret the previous one and drops the one you hold at once, so only rotate again, with a fresh idempotency key, when you can deploy the new secret straight away. Deliveries that fail in between are retried.
551
+
552
+ ## Your account
553
+
554
+ ```js
555
+ const account = await velirapay.account.retrieve();
556
+
557
+ account.displayName;
558
+ account.supportEmail;
559
+ account.supportPhone; // shown to customers next to the support email
560
+ account.logoDarkUrl; // the logo for a dark background, or null
561
+ account.termsUrl;
562
+ account.refundPolicyUrl;
563
+ account.acceptedAssets; // ["BTC", "ETH", …], the coins it takes right now, in the key's mode
564
+ account.mode; // "live" or "test"
565
+ account.type; // "personal" or "organization"
566
+ account.checkout; // appearance, coinOrder, defaultCoin, successUrl, cancelUrl, successMessage, hidePoweredBy
567
+ account.invoices; // numberPrefix, footer, showBusinessDetails
568
+ account.checkoutLocale; // the checkout's fallback language, or null for English
569
+ account.invoiceReminders;
570
+ account.apiPayoutAddressChanges; // whether an owner lets API keys change payout addresses
571
+
572
+ const { business } = account; // as entered under Business details in the dashboard's settings
573
+ business.type; // a BusinessType value, such as BusinessType.Company
574
+ business.legalName;
575
+ business.registrationNumber;
576
+ business.taxId;
577
+ business.address.line1; // also line2, city, postalCode, state and country (ISO 3166 two-letter code)
578
+ business.industry; // an Industry value, such as Industry.Software
579
+ business.productDescription;
580
+ business.monthlyVolume; // a MonthlyVolume value, such as MonthlyVolume.Under50k ("10k_50k")
581
+ business.phone; // in international format
582
+ ```
583
+
584
+ Every business field is `null` until it is filled in. `type`, `industry` and `monthlyVolume` are also `null` for a value the API adds after this version of the library; `business.get('industry')` still returns it as sent. The business details are only sent to a key with the sensitive `account:write` permission, which full access does not include. For any other key every business field is `null`, and so is `account.get('business')`.
585
+
586
+ The plan is on the account too: `account.plan` (`"free"` or `"pro"`), `account.feePercent`, the share of each payment kept as the fee, and `account.proEndsAt`, with `account.isPro()`.
587
+
588
+ A key with the sensitive `account:write` or `payment_settings:write` permission can change the account, in the shape it is read in, with `null` to clear a field:
589
+
590
+ ```js
591
+ import { CheckoutAppearance, CheckoutLocale } from 'velirapay';
592
+
593
+ await velirapay.account.update({ display_name: 'Acme', checkout: { appearance: CheckoutAppearance.Dark } });
594
+ await velirapay.account.updatePaymentSettings({ underpayment_tolerance: '2', checkout_locale: CheckoutLocale.French, invoice_reminders: true });
595
+ ```
596
+
597
+ ### Overview
598
+
599
+ The dashboard's figures for the last 30 days, today included, and what needs a person now. It needs `overview:read`.
600
+
601
+ ```js
602
+ const overview = await velirapay.overview.retrieve();
603
+
604
+ overview.period; // { from, to }, as Dates at midnight UTC
605
+ overview.volume; // "1250.00", in overview.currency
606
+ overview.volumeChangePercent; // against the 30 days before; null when nothing was paid then
607
+ overview.sentVolume; // what the payouts confirmed in the period came to
608
+ overview.paidCharges;
609
+ overview.conversionRatePercent; // the share of paid, expired and canceled charges that were paid; null when none
610
+ overview.funnel; // { started, paid, waiting, abandoned, incomplete, assets }: how attempts to pay ended
611
+ overview.attention; // { underpaidCharges, latePayments, payoutsWaitingForAddress, failingWebhookEndpoints }
612
+ overview.assets; // [{ asset, paidCharges, sharePercent }], each coin's share of the paid charges
613
+ overview.topPaymentLinks; // [{ id, title, paidCharges }]
614
+ overview.setup; // { payoutAddress, paymentLink, paidCharge, webhookEndpoint, publicProfile }
615
+
616
+ for (const day of overview.daily) {
617
+ day.date; // a Date, at midnight UTC
618
+ day.volume; // what was paid that day, in overview.currency
619
+ day.sentVolume;
620
+ day.paidCharges;
621
+ }
622
+
623
+ for (const short of overview.attention.underpaidCharges) {
624
+ short.id; // the charge, to accept or chase
625
+ short.amount; // what it asked for, in its coin
626
+ short.receivedAmount; // what arrived
627
+ }
628
+ ```
629
+
630
+ ### Activity
631
+
632
+ ```js
633
+ import { ActivityAction } from 'velirapay';
634
+
635
+ for await (const entry of velirapay.activity.iterator({ action: 'sensitive' })) { // needs activity:read
636
+ entry.action; // "api_key.created", one of the ActivityAction values
637
+ entry.actor; // a member's name, or 'API key "Shop server"'
638
+ entry.ipAddress;
639
+ }
640
+
641
+ const rotations = await velirapay.activity.list({ action: ActivityAction.WebhookSecretRotated, from: '2026-11-01' });
642
+ ```
643
+
644
+ The log holds the key's mode and what belongs to none, such as sign-ins, whose `mode` is `null`; `network: 'live'` or `'test'` leaves those out. `action: 'sensitive'` narrows it to what touches money or access.
645
+
646
+ ## Members and invitations
647
+
648
+ ```js
649
+ import { MemberRole } from 'velirapay';
650
+
651
+ for (const member of await velirapay.members.list()) { // needs members:read
652
+ member.name;
653
+ member.role; // "owner", "admin", "finance", "support" or "viewer"
654
+ }
655
+
656
+ await velirapay.invitations.create({ email: 'grace@example.com', role: MemberRole.Admin }); // needs members:write
657
+ await velirapay.members.update('3', { role: MemberRole.Finance });
658
+ await velirapay.members.remove('3'); // also revokes the API keys they made
659
+ ```
660
+
661
+ Owners and admins run everything. Finance creates charges, invoices, payment links and coupons, accepts short and late payments, records refunds and sees payouts; support looks up charges and customers, emails payment pages and invoices again and cancels charges; a viewer sees payments and payouts and changes nothing.
662
+
663
+ `members:read` and `members:write` are sensitive. The roles are those of whoever made the key: an owner's or an admin's key invites people and cancels invitations, and only an owner's key changes a role or removes a member. Owners are never changed or removed this way and nobody is made one, so `MemberRole.Owner` only appears in answers: invitations and role changes take an `AssignableMemberRole`.
664
+
665
+ ## API keys
666
+
667
+ ```js
668
+ import { ApiKeyPermission } from 'velirapay';
669
+
670
+ const apiKey = await velirapay.apiKeys.create({
671
+ name: 'Shop server',
672
+ permissions: [ApiKeyPermission.ChargesRead, ApiKeyPermission.ChargesWrite], // leave out for full access
673
+ allowed_ips: ['203.0.113.7'], // leave out for anywhere
674
+ });
675
+
676
+ apiKey.key; // "vp_live_…", shown this once: store it now
677
+
678
+ await velirapay.apiKeys.update(apiKey.id, { name: 'Shop server (old)' });
679
+ await velirapay.apiKeys.revoke(apiKey.id);
680
+ ```
681
+
682
+ A key is made in the mode of the key that makes it. An account can have 10 keys in each mode, and a key can be tied to up to 20 addresses or ranges. `api_keys:read` and `api_keys:write` are sensitive, and a key can make or change no key with a permission or an address it does not have itself, nor touch a key that reaches further.
683
+
684
+ **When the answer that carries the key is lost.** The API never keeps the answer of `create()`, since it carries the key. If it is lost on the way, a repeat with the same idempotency key, which is what the library's own [retry](#retries-and-idempotency) sends, throws a `ConflictError` whose `wasReplayed()` is `true`: the key was made, but cannot be shown again. Find it with `list()` by its `name` and `createdAt`, `revoke()` it, and create another with a fresh idempotency key.
685
+
686
+ ## Pro
687
+
688
+ Pro keeps no fee on your payments. A key with `billing:write`, which full access includes, can start paying for a month of it in crypto, with a live key:
689
+
690
+ ```js
691
+ const payment = await velirapay.billing.startProPayment({ asset: 'BTC' });
133
692
 
134
- A charge needs its coin up front, because its exchange rate, amount and deposit address depend on it. Ask the customer on your own page, and offer them the coins from `velirapay.assets.list()`, which also gives the lowest price each can be charged at.
693
+ payment.checkoutUrl; // where to pay the month
694
+ payment.price; // in payment.currency
695
+ payment.assetAmount; // what to send, in the coin
696
+ payment.started; // false when a payment in that coin was already waiting, which it answers with
697
+ ```
135
698
 
136
699
  ## What the client covers
137
700
 
@@ -154,11 +717,11 @@ A charge needs its coin up front, because its exchange rate, amount and deposit
154
717
  | `invitations` | `create`, `cancel`, `list`, `iterator` |
155
718
  | `billing` | `startProPayment` |
156
719
 
157
- Each takes the parameters the [API reference](https://velirapay.com/docs) gives, typed, and answers with an object such as a `Charge`. `get('field')` reads an attribute this version of the library does not know yet, and `JSON.stringify()` gives the object as the API sent it. Status values are in exported constants, such as `ChargeStatus.Paid` and `Asset.USDT`, and objects have helpers such as `charge.isPaid()`.
720
+ Each takes the parameters the [API reference](https://velirapay.com/docs) gives, typed, and answers with an object such as a `Charge`. `get('field')` reads an attribute this version of the library does not know yet, and `JSON.stringify()` gives the object as the API sent it. Objects have helpers such as `charge.isPaid()`.
158
721
 
159
- Most endpoints need a permission, which a key with full access has, except the sensitive ones, such as `webhook_endpoints:write` and `api_keys:write`: only a key made with them ticked has those. A key without the permission an endpoint needs gets a `PermissionError` naming it.
722
+ Most endpoints need a permission, which a key with full access has, except the sensitive ones: `activity:read`, `account:write`, `payment_settings:write`, `payout_addresses:write`, `webhook_endpoints:write`, `api_keys:read`, `api_keys:write`, `members:read` and `members:write`. Only a key made with them ticked has those, and only while whoever made it is still an owner or admin. A key without the permission an endpoint needs gets a `PermissionError` naming it.
160
723
 
161
- For an endpoint the library has no method for yet, send the request yourself: `await velirapay.request('GET', '/v1/charges', { per_page: 5 })` answers with the response, whose `json()` decodes it.
724
+ For an endpoint the library has no method for yet, send the request yourself: `await velirapay.request('GET', '/v1/charges', { per_page: 5 })` answers with the response, whose `json()` decodes it. Its parameters are sent as they are.
162
725
 
163
726
  ## Lists and pagination
164
727
 
@@ -184,7 +747,9 @@ for await (const charge of velirapay.charges.iterator({ status: 'paid' })) {
184
747
  }
185
748
  ```
186
749
 
187
- `assets.list()` answers with a single page holding every coin, and `payoutAddresses.list()` and `payouts.summary()` with arrays.
750
+ Every page also has `meta`, what the answer said about the list as the API sent it, and `mode` for the lists whose answer says it: the coins on offer and the activity log. `assets.list()` answers with a single page holding every coin, an `AssetPage` whose `currencies` are the currencies prices can be asked in.
751
+
752
+ `payoutAddresses.list()` and `payouts.summary()` answer with plain arrays. The mode they are in is `velirapay.mode()`, and whether an owner lets API keys change payout addresses is `account.apiPayoutAddressChanges`. `velirapay.lastResponse()?.json()` gives the whole answer to the latest request, its `meta` included, but with requests running side by side it is whichever finished last.
188
753
 
189
754
  ## Errors
190
755
 
@@ -193,18 +758,19 @@ A failed request throws an error carrying the API's message. They are exported,
193
758
  | Error | When |
194
759
  | --- | --- |
195
760
  | `AuthenticationError` | The API key is missing, wrong or revoked (401). |
196
- | `PermissionError` | The API key may not do this (403): a restricted key without the permission the endpoint needs, or a key used from an IP address it does not allow. |
761
+ | `PermissionError` | The API key may not do this (403): a restricted key without the permission the endpoint needs, such as `charges:write`, or a key used from an IP address it does not allow. The message names which. An owner or admin can change the key's permissions and addresses in the dashboard; the key itself stays the same. |
197
762
  | `NotFoundError` | The object does not exist, or belongs to the other mode (404). |
198
- | `ConflictError` | The object cannot make that change (409), such as canceling a charge that is no longer pending, or an idempotency key reused for a different request. |
763
+ | `ConflictError` | The object cannot make that change (409), such as canceling a charge once a transfer to it has been seen, accepting one that is neither underpaid nor late, or sending or voiding an invoice that is no longer open. Also when an idempotency key was used for a different request, or is held by a request still being processed, which is [retried](#retries-and-idempotency) for you. `wasReplayed()` is `true` when the key belongs to a request that succeeded but whose answer carried a new API key or signing secret, which cannot be shown again. |
199
764
  | `ValidationError` | The request has invalid fields (422). `errors()` lists them by field, and `firstError('amount')` gives the first message for one. |
200
- | `RateLimitError` | Too many requests (429). `retryAfter()` says how many seconds to wait. |
201
- | `ServerError` | VeliraPay had a problem (5xx). |
202
- | `InvalidRequestError` | Any other 4xx. |
203
- | `ConnectionError` | The API could not be reached, after any retries. |
765
+ | `RateLimitError` | Too many requests (429), including a charge or invoice emailed again within 10 minutes, the hourly limit on emails to customers and the limit on new charges. `retryAfter()` says how many seconds to wait. |
766
+ | `ServerError` | VeliraPay had a problem (5xx), such as no exchange rate being available to create a charge with (503). |
767
+ | `InvalidRequestError` | Any other 4xx, such as an `Idempotency-Key` longer than 255 characters (400). |
768
+ | `UnexpectedResponseError` | The API answered with something the library cannot read, such as a body that is not JSON. |
769
+ | `ConnectionError` | The API could not be reached, after any retries. Its `cause` is the error the `fetch` threw. |
204
770
  | `SignatureVerificationError` | A webhook's signature is missing, does not match, or is too old. |
205
- | `InvalidArgumentError` | The library was given something it cannot use, such as an API key that does not start with `vp_live_` or `vp_test_`. |
771
+ | `InvalidArgumentError` | The library was given something it cannot use, such as an API key that does not start with `vp_live_` or `vp_test_`, or an amount that is not a finite number. |
206
772
 
207
- Every error from an API answer extends `ApiError`, with `status`, `body` (the decoded response), `header(name)` and `retryAfter()`.
773
+ Every error from an API answer extends `ApiError`, with `status`, `body` (the decoded response), `header(name)` and `retryAfter()`, which gives the `Retry-After` in seconds, or `null` when the answer had none.
208
774
 
209
775
  ```js
210
776
  import { ApiError, ValidationError } from 'velirapay';
@@ -214,49 +780,131 @@ try {
214
780
  } catch (error) {
215
781
  if (error instanceof ValidationError) {
216
782
  error.errors(); // { amount: ['…'] }
783
+ error.firstError('amount');
217
784
  } else if (error instanceof ApiError) {
218
785
  error.status;
786
+ error.message;
787
+ error.body;
219
788
  }
220
789
  }
221
790
  ```
222
791
 
223
792
  ## Retries and idempotency
224
793
 
225
- Requests that fail with a network error, a rate limit (429) or an outage (502, 503, 504) are tried again up to twice, waiting a little longer each time, or as long as the API's `Retry-After` header asks. A 500 is retried for reads only, since a write may have been carried out. A `Retry-After` longer than `maxRetryAfter` (10 seconds unless [configured](#configuration) otherwise) is not waited out: the error is thrown straight away, and its `retryAfter()` says how long to wait.
794
+ Requests that fail with a network error, a rate limit (429) or an outage (502, 503, 504, and Cloudflare's 520 to 524) are tried again up to twice, waiting a little longer each time, or as long as the API's `Retry-After` header asks. A 500 is retried for reads only, since a write may have been carried out. A 409 that carries a `Retry-After` is the API still working on an earlier request with the same idempotency key: it is waited out and tried again, and the retry gets that request's answer once it is done. A `Retry-After` longer than `maxRetryAfter` (10 seconds unless [configured](#configuration) otherwise) is not waited out: the error is thrown straight away, and its `retryAfter()` says how long to wait.
226
795
 
227
- While retries are on, each write carries an `Idempotency-Key` header, so the API carries out a retried request once. The library makes up a key per call; pass your own, such as an order number, to make a call safe to repeat across processes:
796
+ Each key may make 120 requests a minute, and each network, an IPv4 address or an IPv6 /64, 600.
797
+
798
+ While retries are on, each write carries an `Idempotency-Key` header, so the API carries out a retried request once. The library makes up a key per call; pass your own to make a call safe to repeat across processes, such as after a crash:
228
799
 
229
800
  ```js
230
- const charge = await velirapay.charges.create(params, { idempotencyKey: 'order-1042' });
801
+ const charge = await velirapay.charges.create({ amount: '49.90', currency: 'EUR', asset: 'BTC' }, { idempotencyKey: 'order-1042-attempt-1' });
802
+
803
+ velirapay.lastResponse()?.wasReplayed(); // true when the API answered from an earlier request
231
804
  ```
232
805
 
233
- For 24 hours, a request repeating a key gets the first successful response, and `velirapay.lastResponse()?.wasReplayed()` is then true. Reusing a key with a different body throws a `ConflictError`. A key covers one account, one mode, one method and one path, and is at most 255 characters.
806
+ What the API does with a key:
807
+
808
+ - Successful (2xx) answers are replayed for 24 hours to requests repeating the key. A request that failed stores nothing, so repeating it runs it again.
809
+ - Reuse a key only to retry the same request. A new attempt needs a new key: a charge replacing one that expired, under the key of the first, would get the expired charge back for a day. Hence keys such as `order-1042-attempt-1`, not the bare order number.
810
+ - An answer that carries a new API key or webhook signing secret is never stored. Repeating the key of `apiKeys.create()`, `webhookEndpoints.create()` or `rotateSecret()`, as the library's own retry does after a lost answer, throws a `ConflictError` whose `wasReplayed()` is `true`, which means the first request succeeded: see [webhook endpoints](#webhook-endpoints) and [API keys](#api-keys) for how to recover. It is the only conflict that is a replay.
811
+ - Reusing a key with a different body or query string, such as another coin, throws a `ConflictError`.
812
+ - A request holds its key for up to 60 seconds while it is being processed. Another one with the same key gets a 409 with `Retry-After: 1` in that time, which the library waits out and retries.
813
+ - A key covers one account, one mode, one HTTP method and one path, and every API key of that account and mode shares it, so two servers with different API keys still create one charge for one idempotency key. It is at most 255 characters, and cannot be blank.
814
+
815
+ ## Constants
816
+
817
+ The values the API uses are in exported constants, each a frozen object with a type of the same name, such as `ChargeStatus.Paid` and `Asset.USDT`. Methods take them or plain strings, and fields that hold one are typed with it, as a string that may also be a value the API adds later.
818
+
819
+ | Constant | Values |
820
+ | --- | --- |
821
+ | `Asset` | Every coin the API knows, on its network, such as `BTC`, `BTC_LIGHTNING`, `USDT` (on Ethereum), `USDT_TRON` and `USDC_BASE` |
822
+ | `ChargeStatus` | `pending`, `underpaid`, `paid`, `expired`, `canceled` |
823
+ | `InvoiceStatus` | `open`, `paid`, `void` |
824
+ | `PaymentLinkStatus` | `active`, `archived` |
825
+ | `PricingType` | `fixed`, `open` |
826
+ | `PayoutStatus` | `pending`, `sending`, `sent`, `failed`, `manual` |
827
+ | `EventType` | Every event webhooks and the events API carry, such as `charge.paid` |
828
+ | `Mode` | `live`, `test` |
829
+ | `Currency` | `USD`, `EUR`, `GBP`, `CAD`, `AUD`, `CHF`, `TRY` |
830
+ | `AddressNetwork` | The networks [payout addresses](#payout-addresses) are for, such as `bitcoin` and `evm` |
831
+ | `ApiKeyPermission` | Every permission an [API key](#api-keys) can have, such as `charges:read` |
832
+ | `MemberRole` | `owner`, `admin`, `finance`, `support`, `viewer`; `AssignableMemberRole` is the type of every one but `owner` |
833
+ | `ActivityAction` | Every action the [activity log](#activity) records, such as `api_key.created` |
834
+ | `WebhookDeliveryStatus` | `pending`, `succeeded`, `failed` |
835
+ | `AccountType` | `personal`, `organization` |
836
+ | `PlanName` | `free`, `pro` |
837
+ | `CheckoutAppearance` | `auto`, `light`, `dark` |
838
+ | `CheckoutLocale` | `en`, `fr`, `de`, `es`, `tr`, `pt`, `ru`, `ar` |
839
+ | `BusinessType`, `Industry`, `MonthlyVolume` | The [business details](#your-account) of an account |
234
840
 
235
841
  ## Test mode
236
842
 
237
- Keys starting with `vp_test_` work in test mode: charges are paid with test coins on each chain's test network, and nothing touches real funds. Test and live objects are fully separate, and each mode has its own webhooks.
843
+ Keys starting with `vp_test_` work in test mode: charges are paid with test coins on each chain's test network, and nothing touches real funds. Test and live objects are fully separate: a live key cannot see test charges, and each mode has its own webhook endpoints. Invoices and charge emails only go to members of your account.
238
844
 
239
845
  ```js
240
846
  velirapay.isTestMode(); // read from the key's prefix
241
847
 
242
- // Pay a test charge without any coins: it becomes paid, and the webhooks go out as for a real payment.
848
+ // Pay a test charge without any coins: it becomes paid, and the webhooks and emails go out as for a real payment.
243
849
  await velirapay.charges.simulate('k3v9x2m7q8wz');
244
850
  await velirapay.charges.simulate('k3v9x2m7q8wz', { amount: '0.001' }); // less than is due, to try an underpayment
245
851
  ```
246
852
 
853
+ BNB Chain, Base, Arbitrum One and Polygon have no test mode, so their coins can only be used with a live key.
854
+
855
+ Every key starts with `vp_live_` or `vp_test_`, and the client throws an `InvalidArgumentError` for any other, which the API would refuse.
856
+
247
857
  ## Configuration
248
858
 
249
859
  ```js
860
+ import { VeliraPayClient } from 'velirapay';
861
+
250
862
  const velirapay = new VeliraPayClient(process.env.VELIRAPAY_API_KEY, {
251
- maxRetries: 2, // 0 turns retries off
252
- maxRetryAfter: 10, // the longest Retry-After, in seconds, waited out before trying again
253
- timeout: 30, // seconds an attempt may take
254
- appInfo: 'MyShop/2.1', // added to the User-Agent
863
+ baseUrl: 'https://api.velirapay.com', // another API address, such as a staging one
864
+ maxRetries: 2, // 0 turns retries off
865
+ maxRetryAfter: 10, // the longest Retry-After, in seconds, waited out before trying again
866
+ timeout: 30, // seconds an attempt may take
867
+ appInfo: 'MyShop/2.1', // added to the User-Agent
255
868
  fetch: globalThis.fetch,
256
869
  });
257
870
  ```
258
871
 
259
- `fetch` takes any implementation of the Fetch API, such as one that goes through a proxy or one that answers your tests.
872
+ `fetch` takes any implementation of the Fetch API, such as one that goes through a proxy or one that answers your tests. A network failure it reports, such as node-fetch's `FetchError`, is retried like one from the built-in `fetch`; any other error it throws passes straight through.
873
+
874
+ ## Testing your integration
875
+
876
+ Sign a payload yourself to test your webhook handler: `Webhook.signatureHeader()` gives the header VeliraPay would send. Run the handler with the secret you sign with, here `VELIRAPAY_WEBHOOK_SECRET=whsec_test`. The [handler above](#webhooks) acknowledges test-mode events without fulfilling them, so this sample says `mode: 'live'` to reach your fulfilment; it goes no further than your own server.
877
+
878
+ ```js
879
+ import { Webhook } from 'velirapay';
880
+
881
+ const payload = JSON.stringify({
882
+ id: 'a5f4b1c2-0000-4000-8000-000000000000',
883
+ event: 'charge.paid',
884
+ mode: 'live',
885
+ created_at: new Date().toISOString(),
886
+ data: { charge: { code: 'k3v9x2m7q8wz', status: 'paid', metadata: { order_id: '1042' } } },
887
+ });
888
+
889
+ await fetch('http://localhost:3000/webhooks/velirapay', {
890
+ method: 'POST',
891
+ headers: { 'Content-Type': 'application/json', [Webhook.SIGNATURE_HEADER]: Webhook.signatureHeader(payload, 'whsec_test') },
892
+ body: payload,
893
+ });
894
+ ```
895
+
896
+ To test code that calls the API, give the client a `fetch` that answers it, or use a test-mode key:
897
+
898
+ ```js
899
+ import { VeliraPayClient } from 'velirapay';
900
+
901
+ const velirapay = new VeliraPayClient('vp_test_example', {
902
+ fetch: async () => Response.json({ data: { id: 'k3v9x2m7q8wz', status: 'paid', checkout_url: 'https://velirapay.com/c/k3v9x2m7q8wz' } }),
903
+ });
904
+
905
+ const charge = await velirapay.charges.retrieve('k3v9x2m7q8wz');
906
+ charge.isPaid(); // true
907
+ ```
260
908
 
261
909
  ## Development
262
910