@coinvello/mcp 0.0.0-stage → 0.1.1
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 +194 -2
- package/dist/client.d.ts +32 -0
- package/dist/client.js +86 -0
- package/dist/docs/llms-full.txt +792 -0
- package/dist/docs/llms.txt +56 -0
- package/dist/docs/openapi.yaml +8045 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +85 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.js +442 -0
- package/dist/verify.d.ts +17 -0
- package/dist/verify.js +58 -0
- package/package.json +57 -4
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
# CoinVello API: complete integration guide for AI assistants
|
|
2
|
+
|
|
3
|
+
> This single document is enough to integrate CoinVello end to end. It follows the llms.txt convention
|
|
4
|
+
> (https://llmstxt.org). The short index is at https://coinvello.com/llms.txt; the machine-readable
|
|
5
|
+
> OpenAPI 3.1 reference is at https://coinvello.com/.well-known/openapi.yaml. Version 2026-10-01.
|
|
6
|
+
|
|
7
|
+
## 0. What CoinVello is (say this correctly)
|
|
8
|
+
|
|
9
|
+
CoinVello (https://coinvello.com) is a **non-custodial crypto payment processor** for online merchants.
|
|
10
|
+
|
|
11
|
+
- **Non-custodial.** Payments go directly from the payer's wallet to an address the merchant controls.
|
|
12
|
+
For Bitcoin the merchant registers an **xpub** and CoinVello derives a fresh receiving
|
|
13
|
+
address per invoice. For Ethereum, Base, Arbitrum, Optimism, Polygon, BNB Chain, Avalanche, Robinhood
|
|
14
|
+
Chain, Solana and Tron the merchant registers an **address they own**. CoinVello never holds crypto or fiat, so
|
|
15
|
+
there is no balance, no payout and no withdrawal step: the money is already in the merchant's wallet.
|
|
16
|
+
- **No KYC, no KYB.** Signup is an email address and an API key. The API collects no identity data
|
|
17
|
+
about merchants or payers. The only verification that can ever happen is on a third-party partner's
|
|
18
|
+
side if the merchant chooses to connect an off-ramp account (optional).
|
|
19
|
+
- **Fees are prepaid processing credits, bought in crypto.** No percentage cut, no per-transaction
|
|
20
|
+
cents. 1 credit = $0.01 nominal. Each confirmed payment consumes roughly 30 bps of its fiat value
|
|
21
|
+
(floor 2 credits, cap 2,000 credits). Bundles get cheaper per credit with volume, giving an
|
|
22
|
+
effective rate of ~0.30% (Starter) down to under 0.10% (Enterprise). There are no free live
|
|
23
|
+
credits; an account that does not want to prepay uses **pay as you go** (0.4% split on-chain,
|
|
24
|
+
open to every account, section 3b). Test mode credits cost nothing and never run out. Live prices come
|
|
25
|
+
from `GET /credits/bundles`.
|
|
26
|
+
- **11 networks**: bitcoin, ethereum, base, arbitrum, optimism, polygon, bnb, avalanche,
|
|
27
|
+
robinhood, solana, tron. Assets: native coins plus USDC (ethereum, base, arbitrum, optimism, polygon,
|
|
28
|
+
bnb, avalanche, solana), USDT (ethereum, base, arbitrum, optimism, polygon, bnb, avalanche, solana, tron),
|
|
29
|
+
DAI (ethereum, base, arbitrum, optimism, polygon), EURC (ethereum, base, avalanche, solana), PYUSD
|
|
30
|
+
(ethereum) and USDG (`usdg:robinhood`, Paxos Global Dollar, on Robinhood Chain, EVM chain id 4663).
|
|
31
|
+
Bitcoin tokens (not stablecoins, priced at the BTC rate): WBTC (ethereum, base, arbitrum, optimism,
|
|
32
|
+
polygon), cbBTC (ethereum, base, arbitrum, solana), BTCB (`btcb:bnb`, 18 decimals) and BTC.b
|
|
33
|
+
(`btcb:avalanche`). Tokens without an official testnet contract are live-only (absent from test mode):
|
|
34
|
+
DAI, `usdc:bnb` (Binance-Peg, 18 decimals), USDT on base, arbitrum (USD₮0), optimism, avalanche and
|
|
35
|
+
solana, and every Bitcoin token (`wbtc:*`, `cbbtc:*`, `btcb:*`).
|
|
36
|
+
- **Integration surfaces**: hosted checkout (pay.coinvello.com), embeddable widget
|
|
37
|
+
(`@coinvello/checkout`), REST API with SDKs (`@coinvello/sdk` on npm; `coinvello/sdk` and `coinvello` hosted at
|
|
38
|
+
https://coinvello.com/downloads/sdk/ until the Packagist and PyPI releases), plugins (WooCommerce available now; Shopify pending Shopify's approval; Magento 2, PrestaShop, OpenCart in testing),
|
|
39
|
+
MCP server (`@coinvello/mcp`).
|
|
40
|
+
|
|
41
|
+
## 1. Conventions
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
Base URL https://api.coinvello.com/v1
|
|
45
|
+
Auth Authorization: Bearer cv_test_... (test mode) | cv_live_... (live mode)
|
|
46
|
+
Content type application/json (UTF-8)
|
|
47
|
+
Idempotency Idempotency-Key: <unique string, <= 255 chars> on every POST (replayed for 24 h)
|
|
48
|
+
Version pin CoinVello-Version: 2026-10-01 (optional)
|
|
49
|
+
Amounts decimal STRINGS, e.g. "49.99", "0.00074230". Never floats.
|
|
50
|
+
Asset ids "<asset>:<chain>", e.g. "btc:bitcoin", "eth:ethereum", "usdc:polygon", "usdt:tron", "sol:solana"
|
|
51
|
+
Ids inv_ (invoice) pay_ (payment) wal_ (wallet) whe_ (webhook endpoint) evt_ (event)
|
|
52
|
+
cs_ (checkout session) cpu_ (credit purchase) rf_ (refund) cnv_ (conversion) ofp_ (off-ramp payout)
|
|
53
|
+
Lists GET ...?limit=20&cursor=<next_cursor> -> { "object": "list", "data": [...], "has_more": true, "next_cursor": "..." }
|
|
54
|
+
Errors { "error": { "type", "code", "message", "param?", "request_id", "doc_url?", "details?" } }
|
|
55
|
+
Rate limit 100 req/s per key (burst 200); 429 carries Retry-After
|
|
56
|
+
Local mock http://localhost:4242/v1 (api/reference-server, accepts any cv_test_ key)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Test mode is a separate universe: test wallets, invoices, webhooks and credits are invisible to live
|
|
60
|
+
keys and vice versa. Test-mode chains map to testnet4, sepolia, base-sepolia,
|
|
61
|
+
arbitrum-sepolia, optimism-sepolia, amoy, bnb-testnet, fuji,
|
|
62
|
+
robinhood-testnet (chain id 46630), devnet, nile.
|
|
63
|
+
|
|
64
|
+
Key types: `cv_test_` / `cv_live_` (full merchant keys), `cv_test_rk_` / `cv_live_rk_` (restricted
|
|
65
|
+
keys with scopes such as `invoices:write checkout_sessions:write webhooks:write events:read`).
|
|
66
|
+
Merchants copy keys from Settings -> API keys (behind a second-factor check); keep them server-side only. Never put an API key in client-side code.
|
|
67
|
+
|
|
68
|
+
## 2. The recommended integration in five steps
|
|
69
|
+
|
|
70
|
+
1. Register a wallet (`POST /wallets`) - once, usually from the dashboard. It is usable at once in
|
|
71
|
+
live and test mode; addresses are validated strictly; a signed-message proof is optional.
|
|
72
|
+
2. Create a **checkout session** (`POST /checkout-sessions`) when the customer wants to pay.
|
|
73
|
+
3. **Redirect** the customer to `session.url` (`https://pay.coinvello.com/c/cs_...`). The hosted page
|
|
74
|
+
shows asset choice, QR code, wallet buttons, live status, and redirects to `success_url`.
|
|
75
|
+
4. Create a **webhook endpoint** (`POST /webhooks`) once; on each delivery verify
|
|
76
|
+
`CoinVello-Signature`, dedupe on `event.id`, and **fulfil the order on `invoice.paid`**.
|
|
77
|
+
5. In test mode, trigger the whole pipeline with `POST /test/simulate_payment`.
|
|
78
|
+
|
|
79
|
+
Fulfil from webhooks (or by re-fetching the invoice with your API key), never from the redirect:
|
|
80
|
+
the redirect URL can be opened by anyone.
|
|
81
|
+
|
|
82
|
+
### 2.1 Register a wallet
|
|
83
|
+
|
|
84
|
+
Bitcoin (xpub; a fresh address per invoice):
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
curl https://api.coinvello.com/v1/wallets \
|
|
88
|
+
-H "Authorization: Bearer $COINVELLO_API_KEY" \
|
|
89
|
+
-H "Content-Type: application/json" \
|
|
90
|
+
-d '{"type":"xpub","chain":"bitcoin","label":"Shop BTC","xpub":"vpub5Y6cjg7...","derivation_path":"m/84'"'"'/1'"'"'/0'"'"'"}'
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
EVM / Solana / Tron (merchant-owned address):
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
curl https://api.coinvello.com/v1/wallets \
|
|
97
|
+
-H "Authorization: Bearer $COINVELLO_API_KEY" \
|
|
98
|
+
-H "Content-Type: application/json" \
|
|
99
|
+
-d '{"type":"address","chain":"polygon","label":"Polygon hot wallet","address":"0x8ba1f109551bD432803012645Ac136ddd64DBA72","assets":["usdc:polygon","usdt:polygon"]}'
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`WalletCreateRequest` fields: `type` (xpub|address, required), `chain` (required), `label` (required),
|
|
103
|
+
`xpub`, `derivation_path` (e.g. `m/84'/0'/0'`), `address_type` (p2pkh|p2sh-p2wpkh|p2wpkh|p2tr),
|
|
104
|
+
`start_index`, `address`, `assets[]`, `enabled`, `metadata`. Response: `Wallet` with `id` (`wal_`),
|
|
105
|
+
`status` (active|verified|archived; `unverified` is a legacy synonym of active), `assets[]`,
|
|
106
|
+
`first_address` (xpub wallets: compare it with the wallet's first receive address), and
|
|
107
|
+
`verification.status` (`not_required` by default; signing a challenge is optional). Never send an xprv:
|
|
108
|
+
it is rejected with `422 xprv_rejected` and you must rotate it. Bad addresses (wrong checksum, wrong
|
|
109
|
+
network, zero/burn address, a token contract) are refused with `422 address_invalid`; Bitcoin
|
|
110
|
+
needs an xpub (`422 xpub_required`).
|
|
111
|
+
|
|
112
|
+
### 2.2 Create a checkout session
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
curl https://api.coinvello.com/v1/checkout-sessions \
|
|
116
|
+
-H "Authorization: Bearer $COINVELLO_API_KEY" \
|
|
117
|
+
-H "Content-Type: application/json" \
|
|
118
|
+
-H "Idempotency-Key: order-8812-checkout" \
|
|
119
|
+
-d '{
|
|
120
|
+
"amount": "129.00",
|
|
121
|
+
"currency": "EUR",
|
|
122
|
+
"accepted_assets": ["btc:bitcoin", "eth:ethereum", "usdt:tron", "usdc:polygon"],
|
|
123
|
+
"order_id": "SHOP-8812",
|
|
124
|
+
"success_url": "https://shop.example.com/thanks?order=8812",
|
|
125
|
+
"cancel_url": "https://shop.example.com/cart",
|
|
126
|
+
"customer": { "email": "buyer@example.com" },
|
|
127
|
+
"line_items": [{ "name": "Mechanical keyboard", "quantity": 1, "unit_amount": "129.00" }],
|
|
128
|
+
"redirect_on": "confirming",
|
|
129
|
+
"metadata": { "order_id": "8812" }
|
|
130
|
+
}'
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Response `201 CheckoutSession`:
|
|
134
|
+
|
|
135
|
+
```json
|
|
136
|
+
{
|
|
137
|
+
"id": "cs_5TgH8kLp2Q",
|
|
138
|
+
"object": "checkout_session",
|
|
139
|
+
"livemode": false,
|
|
140
|
+
"status": "open",
|
|
141
|
+
"url": "https://pay.coinvello.com/c/cs_5TgH8kLp2Q",
|
|
142
|
+
"invoice_id": "inv_9XyZ12AbC3",
|
|
143
|
+
"invoice_status": "new",
|
|
144
|
+
"amount": "129.00",
|
|
145
|
+
"currency": "EUR",
|
|
146
|
+
"order_id": "SHOP-8812",
|
|
147
|
+
"success_url": "https://shop.example.com/thanks?order=8812",
|
|
148
|
+
"cancel_url": "https://shop.example.com/cart",
|
|
149
|
+
"redirect_on": "confirming",
|
|
150
|
+
"expires_at": "2026-10-21T10:30:00Z",
|
|
151
|
+
"created_at": "2026-10-21T10:00:00Z"
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`CheckoutSessionCreateRequest` = every `InvoiceCreateRequest` field plus `success_url` (required),
|
|
156
|
+
`line_items[]` (`name`, `quantity`, `unit_amount`, `description?`, `image_url?`), `redirect_on`
|
|
157
|
+
(`confirming` default | `paid`), `locale`, `branding` (`theme` light|dark|auto, `logo_url`,
|
|
158
|
+
`primary_color` `#RRGGBB`, `merchant_name`), `collect_email`, `allow_asset_change`.
|
|
159
|
+
|
|
160
|
+
`InvoiceCreateRequest` fields: `amount` (required, decimal string), `currency` (required; ISO 4217
|
|
161
|
+
such as USD/EUR/GBP or an asset id such as `btc:bitcoin` to price in crypto), `accepted_assets[]`
|
|
162
|
+
(defaults to all eligible), `order_id` (<=128, indexed), `description`, `customer`
|
|
163
|
+
(`email`, `name`, `reference`), `success_url`, `cancel_url`, `expires_in` (300..86400 s, default 1800),
|
|
164
|
+
`rate_lock` (default true), `rate_lock_id`, `required_confirmations` (per asset override),
|
|
165
|
+
`underpayment_tolerance` (default "0.005"), `overpayment_tolerance` (default "0.02"),
|
|
166
|
+
`payment_options_mode` (all|single), `metadata` (<=50 string pairs).
|
|
167
|
+
|
|
168
|
+
Checkout session `status`: `open` -> `complete` | `expired`. The underlying invoice carries the money
|
|
169
|
+
state: `new -> pending -> confirming -> paid`, or `underpaid`, `overpaid`, `expired`, `cancelled`,
|
|
170
|
+
`refund_requested`. Alias path `/checkout_sessions` is accepted. `GET /checkout-sessions/{id}?expand=invoice`
|
|
171
|
+
embeds the invoice. `POST /checkout-sessions/{id}/expire` closes it.
|
|
172
|
+
|
|
173
|
+
Public, unauthenticated, CORS-open endpoints used by the hosted page and widget (never need your key):
|
|
174
|
+
`GET /checkout-sessions/{id}/public` (redacted `PublicCheckoutSession`) and
|
|
175
|
+
`GET /checkout-sessions/{id}/events` (Server-Sent Events: `status` on every change, `heartbeat` every 15 s).
|
|
176
|
+
|
|
177
|
+
### 2.3 Or create a bare invoice (bring your own UI)
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
curl https://api.coinvello.com/v1/invoices \
|
|
181
|
+
-H "Authorization: Bearer $COINVELLO_API_KEY" \
|
|
182
|
+
-H "Content-Type: application/json" \
|
|
183
|
+
-H "Idempotency-Key: order-10042-invoice" \
|
|
184
|
+
-d '{"amount":"49.99","currency":"USD","accepted_assets":["btc:bitcoin","usdc:polygon"],"order_id":"WC-10042","success_url":"https://shop.example.com/thanks","metadata":{"woocommerce_order_id":"10042"}}'
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Response `201 Invoice` (abridged):
|
|
188
|
+
|
|
189
|
+
```json
|
|
190
|
+
{
|
|
191
|
+
"id": "inv_9XyZ12AbC3",
|
|
192
|
+
"object": "invoice",
|
|
193
|
+
"livemode": false,
|
|
194
|
+
"status": "new",
|
|
195
|
+
"amount": "49.99",
|
|
196
|
+
"currency": "USD",
|
|
197
|
+
"pricing": "fiat",
|
|
198
|
+
"accepted_assets": ["btc:bitcoin", "usdc:polygon"],
|
|
199
|
+
"hosted_url": "https://pay.coinvello.com/i/inv_9XyZ12AbC3",
|
|
200
|
+
"payment_options": [
|
|
201
|
+
{
|
|
202
|
+
"asset": "btc:bitcoin", "symbol": "BTC", "chain": "bitcoin", "network_id": null,
|
|
203
|
+
"address": "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx",
|
|
204
|
+
"amount": "0.00074230", "amount_minor": "74230", "rate": "67350.12",
|
|
205
|
+
"payment_uri": "bitcoin:tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx?amount=0.0007423",
|
|
206
|
+
"qr_payload": "bitcoin:tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx?amount=0.0007423",
|
|
207
|
+
"required_confirmations": 1, "status": "available"
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
"asset": "usdc:polygon", "symbol": "USDC", "chain": "polygon", "network_id": "80002",
|
|
211
|
+
"address": "0x8ba1f109551bD432803012645Ac136ddd64DBA72",
|
|
212
|
+
"contract_address": "0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582", "decimals": 6,
|
|
213
|
+
"amount": "50.000518", "amount_minor": "50000518", "rate": "0.9999",
|
|
214
|
+
"payment_uri": "ethereum:0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582@80002/transfer?address=0x8ba1f109551bD432803012645Ac136ddd64DBA72&uint256=50000518",
|
|
215
|
+
"qr_payload": "ethereum:0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582@80002/transfer?address=0x8ba1f109551bD432803012645Ac136ddd64DBA72&uint256=50000518",
|
|
216
|
+
"required_confirmations": 64, "status": "available"
|
|
217
|
+
}
|
|
218
|
+
],
|
|
219
|
+
"paid": null,
|
|
220
|
+
"order_id": "WC-10042",
|
|
221
|
+
"rate_lock": { "locked": true, "locked_until": "2026-10-21T10:30:00Z", "rates": { "btc:bitcoin": "67350.12", "usdc:polygon": "0.9999" } },
|
|
222
|
+
"metadata": { "woocommerce_order_id": "10042" },
|
|
223
|
+
"expires_at": "2026-10-21T10:30:00Z",
|
|
224
|
+
"created_at": "2026-10-21T10:00:00Z"
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Render a QR from `qr_payload` and a wallet deep link from `payment_uri` (BIP-21 for BTC, EIP-681
|
|
229
|
+
for EVM, Solana Pay for Solana, `tron:` for Tron). Once a payment is detected, `invoice.paid`
|
|
230
|
+
(the object field) holds `amount_received`, `amount_remaining`, `confirmations`, `payment_ids`.
|
|
231
|
+
Other invoice endpoints: `GET /invoices?status=&order_id=&limit=&cursor=`, `GET /invoices/{id}`,
|
|
232
|
+
`PATCH /invoices/{id}` (metadata/description), `POST /invoices/{id}/cancel`, `POST /invoices/{id}/expire`,
|
|
233
|
+
`GET /invoices/{id}/payments`.
|
|
234
|
+
|
|
235
|
+
### 2.4 Create a webhook endpoint
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
curl https://api.coinvello.com/v1/webhooks \
|
|
239
|
+
-H "Authorization: Bearer $COINVELLO_API_KEY" \
|
|
240
|
+
-H "Content-Type: application/json" \
|
|
241
|
+
-d '{"url":"https://shop.example.com/webhooks/coinvello","description":"Shop production","events":["invoice.paid","invoice.confirming","invoice.underpaid","invoice.overpaid","invoice.expired","payment.detected","checkout_session.completed"]}'
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Response `201 Webhook` includes `"secret": "whsec_<64 hex>"` **once**. Store it as `COINVELLO_WEBHOOK_SECRET`.
|
|
245
|
+
Live endpoints must be HTTPS; test endpoints may be `http://localhost`. Use `"events": ["*"]` for everything.
|
|
246
|
+
`POST /webhooks/{id}/test` sends a signed ping. `POST /webhooks/{id}/rotate_secret` rotates with a
|
|
247
|
+
grace period during which deliveries carry two `v1=` signatures. Up to 16 endpoints per mode.
|
|
248
|
+
|
|
249
|
+
Every delivery:
|
|
250
|
+
|
|
251
|
+
```
|
|
252
|
+
POST /webhooks/coinvello
|
|
253
|
+
Content-Type: application/json
|
|
254
|
+
User-Agent: CoinVello-Webhooks/1.0 (+https://coinvello.com/webhooks)
|
|
255
|
+
CoinVello-Signature: t=1760000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
|
|
256
|
+
CoinVello-Event-Id: evt_1Nz7Qw3Er9
|
|
257
|
+
CoinVello-Event-Type: invoice.paid
|
|
258
|
+
CoinVello-Webhook-Id: whe_2JxK9vL3mQ
|
|
259
|
+
CoinVello-Delivery-Id: whd_4Hn7Pq2W
|
|
260
|
+
CoinVello-Delivery-Attempt: 1
|
|
261
|
+
|
|
262
|
+
{
|
|
263
|
+
"id": "evt_1Nz7Qw3Er9",
|
|
264
|
+
"object": "event",
|
|
265
|
+
"type": "invoice.paid",
|
|
266
|
+
"livemode": true,
|
|
267
|
+
"api_version": "2026-10-01",
|
|
268
|
+
"created_at": "2026-10-21T10:04:12Z",
|
|
269
|
+
"data": {
|
|
270
|
+
"object": { "id": "inv_9XyZ12AbC3", "object": "invoice", "status": "paid", "order_id": "WC-10042", "metadata": { "woocommerce_order_id": "10042" }, "...": "..." },
|
|
271
|
+
"previous_attributes": { "status": "confirming" }
|
|
272
|
+
},
|
|
273
|
+
"request": { "id": "req_...", "idempotency_key": "order-10042-invoice" },
|
|
274
|
+
"pending_webhooks": 1
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Respond `2xx` within 10 s. Retries: 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, 24 h (9 attempts,
|
|
279
|
+
~70 h). Ordering is not guaranteed: act on `data.object.status`, not on the sequence of events.
|
|
280
|
+
Endpoints are `degraded` after 3 consecutive failures and auto-disabled after 72 h of failures.
|
|
281
|
+
|
|
282
|
+
Event types: `invoice.created`, `invoice.pending`, `invoice.confirming` (safe to show "payment
|
|
283
|
+
received"), `invoice.paid` (**fulfil**), `invoice.underpaid`, `invoice.overpaid`, `invoice.expired`,
|
|
284
|
+
`invoice.cancelled`, `invoice.refund_requested`, `payment.detected`, `payment.confirmed`,
|
|
285
|
+
`payment.failed`, `wallet.verified`, `checkout_session.completed`, `checkout_session.expired`,
|
|
286
|
+
`credits.low`, `credits.added`, `credits.purchase.paid` / `.underpaid` / `.expired`,
|
|
287
|
+
`refund.completed` / `.failed`, `conversion.completed` / `.failed`, `offramp.completed` / `.failed`.
|
|
288
|
+
|
|
289
|
+
### 2.5 Verify the webhook signature (exact scheme)
|
|
290
|
+
|
|
291
|
+
```
|
|
292
|
+
CoinVello-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>[,v1=<hex HMAC-SHA256>]
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
1. Read the **raw request body bytes** before any JSON/body-parser middleware touches them.
|
|
296
|
+
2. Parse the header into `t` and one or more `v1` values.
|
|
297
|
+
3. Reject if `t` is missing or `|now - t| > 300` seconds.
|
|
298
|
+
4. `expected = hex(HMAC_SHA256(key = secret as UTF-8 including the "whsec_" prefix, message = "<t>." + raw_body))`.
|
|
299
|
+
5. Compare `expected` against each `v1` with a constant-time comparison. Valid if any matches.
|
|
300
|
+
6. Dedupe on `event.id` (keep processed ids for at least 3 days). Only then parse JSON and act.
|
|
301
|
+
|
|
302
|
+
Node (no dependencies):
|
|
303
|
+
|
|
304
|
+
```js
|
|
305
|
+
import crypto from "node:crypto";
|
|
306
|
+
|
|
307
|
+
export function verifyCoinVelloSignature(rawBody, header, secret, toleranceSec = 300) {
|
|
308
|
+
if (!header) throw new Error("missing CoinVello-Signature");
|
|
309
|
+
let t = 0; const sigs = [];
|
|
310
|
+
for (const part of header.split(",")) {
|
|
311
|
+
const [k, ...rest] = part.trim().split("="); const v = rest.join("=");
|
|
312
|
+
if (k === "t") t = Number(v);
|
|
313
|
+
if (k === "v1") sigs.push(v);
|
|
314
|
+
}
|
|
315
|
+
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) throw new Error("timestamp outside tolerance");
|
|
316
|
+
const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, "utf8");
|
|
317
|
+
const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(body).digest();
|
|
318
|
+
const ok = sigs.some((s) => {
|
|
319
|
+
const b = Buffer.from(s, "hex");
|
|
320
|
+
return b.length === expected.length && crypto.timingSafeEqual(b, expected);
|
|
321
|
+
});
|
|
322
|
+
if (!ok) throw new Error("signature mismatch");
|
|
323
|
+
return JSON.parse(body.toString("utf8"));
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Python (stdlib only):
|
|
328
|
+
|
|
329
|
+
```python
|
|
330
|
+
import hmac, hashlib, json, time
|
|
331
|
+
|
|
332
|
+
def verify_coinvello_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> dict:
|
|
333
|
+
t, sigs = 0, []
|
|
334
|
+
for part in (header or "").split(","):
|
|
335
|
+
k, _, v = part.strip().partition("=")
|
|
336
|
+
if k == "t":
|
|
337
|
+
t = int(v)
|
|
338
|
+
elif k == "v1":
|
|
339
|
+
sigs.append(v)
|
|
340
|
+
if not t or abs(time.time() - t) > tolerance:
|
|
341
|
+
raise ValueError("timestamp outside tolerance")
|
|
342
|
+
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
|
|
343
|
+
if not any(hmac.compare_digest(expected, s) for s in sigs):
|
|
344
|
+
raise ValueError("signature mismatch")
|
|
345
|
+
return json.loads(raw_body)
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
PHP (plain, WordPress, Laravel):
|
|
349
|
+
|
|
350
|
+
```php
|
|
351
|
+
<?php
|
|
352
|
+
function coinvello_verify(string $raw, string $header, string $secret, int $tolerance = 300): array
|
|
353
|
+
{
|
|
354
|
+
$t = 0; $sigs = [];
|
|
355
|
+
foreach (explode(',', $header) as $pair) {
|
|
356
|
+
$kv = explode('=', trim($pair), 2);
|
|
357
|
+
if (count($kv) !== 2) continue;
|
|
358
|
+
if ($kv[0] === 't') { $t = (int) $kv[1]; }
|
|
359
|
+
if ($kv[0] === 'v1') { $sigs[] = $kv[1]; }
|
|
360
|
+
}
|
|
361
|
+
if ($t === 0 || abs(time() - $t) > $tolerance) {
|
|
362
|
+
throw new RuntimeException('timestamp outside tolerance');
|
|
363
|
+
}
|
|
364
|
+
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
|
|
365
|
+
foreach ($sigs as $sig) {
|
|
366
|
+
if (hash_equals($expected, $sig)) {
|
|
367
|
+
return json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
throw new RuntimeException('signature mismatch');
|
|
371
|
+
}
|
|
372
|
+
// $event = coinvello_verify(file_get_contents('php://input'), $_SERVER['HTTP_COINVELLO_SIGNATURE'] ?? '', getenv('COINVELLO_WEBHOOK_SECRET'));
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
With the SDKs: Node `cv.webhooks.constructEvent(rawBody, header, secret)`, PHP
|
|
376
|
+
`\CoinVello\Sdk\Webhooks::constructEvent($raw, $header, $secret)`, Python
|
|
377
|
+
`coinvello.Webhooks.construct_event(raw, header, secret)`. All throw on failure.
|
|
378
|
+
|
|
379
|
+
Test vector (useful for unit tests): secret `whsec_test`, `t = 1760000000`, body `{"id":"evt_1","type":"ping"}`
|
|
380
|
+
-> signed string `1760000000.{"id":"evt_1","type":"ping"}`; compute `v1` with the code above and
|
|
381
|
+
assert that changing one body byte fails.
|
|
382
|
+
|
|
383
|
+
### 2.6 Poll an invoice (fallback or reconciliation)
|
|
384
|
+
|
|
385
|
+
```bash
|
|
386
|
+
curl https://api.coinvello.com/v1/invoices/inv_9XyZ12AbC3 -H "Authorization: Bearer $COINVELLO_API_KEY"
|
|
387
|
+
curl "https://api.coinvello.com/v1/checkout-sessions/cs_5TgH8kLp2Q?expand=invoice" -H "Authorization: Bearer $COINVELLO_API_KEY"
|
|
388
|
+
curl "https://api.coinvello.com/v1/invoices?order_id=WC-10042" -H "Authorization: Bearer $COINVELLO_API_KEY"
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Poll every 5 s while the status is non-terminal, backing off to 60 s on errors. Terminal statuses:
|
|
392
|
+
`paid`, `underpaid`, `overpaid`, `expired`, `cancelled`. For high-value flows re-fetch the invoice
|
|
393
|
+
with your API key before fulfilling, even after a verified webhook.
|
|
394
|
+
|
|
395
|
+
### 2.7 Simulate a payment in test mode
|
|
396
|
+
|
|
397
|
+
```bash
|
|
398
|
+
curl https://api.coinvello.com/v1/test/simulate_payment \
|
|
399
|
+
-H "Authorization: Bearer $COINVELLO_API_KEY" \
|
|
400
|
+
-H "Content-Type: application/json" \
|
|
401
|
+
-d '{"invoice_id":"inv_9XyZ12AbC3","asset":"usdc:polygon","confirmations":64}'
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
`SimulatePaymentRequest`: `invoice_id` (`inv_` or `cpu_`, required), `asset`, `amount` (less/more to
|
|
405
|
+
simulate underpaid/overpaid), `confirmations` (0 = detected only; >= required = confirmed),
|
|
406
|
+
`tx_hash` (reuse to advance the same payment), `from_address`, `delay_seconds` (0..300).
|
|
407
|
+
Test keys only (`403 test_mode_only` otherwise). It runs the real state machine and fires real
|
|
408
|
+
signed webhooks.
|
|
409
|
+
|
|
410
|
+
## 3. Credits (how fees work in the API)
|
|
411
|
+
|
|
412
|
+
```bash
|
|
413
|
+
curl https://api.coinvello.com/v1/credits/balance -H "Authorization: Bearer $COINVELLO_API_KEY"
|
|
414
|
+
# {"object":"credits_balance","livemode":true,"balance":1852,"available":1840,"reserved":12,"low_balance_threshold":200,
|
|
415
|
+
# "estimated_payments_remaining":76,"auto_topup":{"enabled":true,"bundle_id":"bnd_growth_15000","trigger_below":200},...}
|
|
416
|
+
# balance = available + reserved, never negative; reserved = estimated fees of open invoices paid with credits.
|
|
417
|
+
|
|
418
|
+
curl https://api.coinvello.com/v1/credits/bundles -H "Authorization: Bearer $COINVELLO_API_KEY"
|
|
419
|
+
# list of CreditBundle: id, name, credits, bonus_credits, price {amount,currency:"USD"}, price_per_credit,
|
|
420
|
+
# crypto_prices[] {asset, amount, rate}, rates_as_of
|
|
421
|
+
|
|
422
|
+
curl https://api.coinvello.com/v1/credits/purchases \
|
|
423
|
+
-H "Authorization: Bearer $COINVELLO_API_KEY" -H "Content-Type: application/json" \
|
|
424
|
+
-d '{"bundle_id":"bnd_growth_15000","pay_asset":"usdt","pay_chain":"tron"}'
|
|
425
|
+
# CreditPurchase: id cpu_..., status (invoice lifecycle), credits, price, checkout_url
|
|
426
|
+
# (https://pay.coinvello.com/credits/cpu_...), payment.payment_options[] with the operator wallet address.
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
Pay the returned payment option (or open `checkout_url`); credits are added at `paid`/`overpaid`
|
|
430
|
+
and `credits.added` fires. Merchants can also pay by card: cards are saved in the dashboard
|
|
431
|
+
(Square card form + 2FA; API keys cannot add cards), listed by `GET /credits/payment_methods`, and
|
|
432
|
+
charged with `POST /credits/purchases {"bundle_id":"...","payment_method":"pm_..."}`, which returns
|
|
433
|
+
`paid` at once or `402 card_error / card_declined`. `GET /credits/ledger` lists every debit and
|
|
434
|
+
credit (`reason`: invoice_fee, checkout_fee, purchase, purchase_reversal, bonus, ...).
|
|
435
|
+
`PUT /credits/auto_topup` buys a bundle with the saved card when the balance drops below a
|
|
436
|
+
threshold (`monthly_limit` optional; card-only, since crypto cannot be pulled).
|
|
437
|
+
|
|
438
|
+
Running out: the balance never goes below zero (no deficit, no overage). Creating an invoice paid with
|
|
439
|
+
credits reserves its estimated fee (`clamp(round(usd x 30 bps / $0.01), 2, 2000)`); a payment larger than
|
|
440
|
+
the invoice is charged at most what the account holds. When `available` cannot cover a new invoice, an
|
|
441
|
+
account with `auto_switch_to_pay_as_you_go: true` (the default; `PATCH /account` to change it) switches to
|
|
442
|
+
`billing_plan: "pay_as_you_go"` and keeps taking payments, and the account email is notified (only where
|
|
443
|
+
pay as you go is open to the account, `pay_as_you_go.available`). Otherwise creating invoices returns
|
|
444
|
+
`402 credits_error / insufficient_credits` with `details: { available, required, bundles_url }`;
|
|
445
|
+
detection of already-issued invoices continues. Bitcoin payments use credits even on pay as you go.
|
|
446
|
+
|
|
447
|
+
Indicative bundle tiers (from the pricing model; the API is authoritative): Starter 2,500 for $25 (0.30%); Growth 15,000 for $120 (0.24%); Scale 75,000 for $450 (0.18%);
|
|
448
|
+
Volume 400,000 for $1,600 (0.12%); Enterprise 2M+ from $6,000 (<= 0.09%). Purchased credits last
|
|
449
|
+
24 months.
|
|
450
|
+
|
|
451
|
+
## 3b. Pay as you go
|
|
452
|
+
|
|
453
|
+
The alternative to credits: `PATCH /account {"billing_plan":"pay_as_you_go"}` (API key, or a dashboard session
|
|
454
|
+
with a 2FA step-up). CoinVello then takes 0.4% (`fee_bps: 40`) of each payment on-chain, nothing up front.
|
|
455
|
+
`GET /account` shows `billing_plan` and `pay_as_you_go.chains[].status` (enabled / sweep_only / disabled /
|
|
456
|
+
not_supported). Bitcoin keeps using credits. A prepaid account also lands here automatically when its credits
|
|
457
|
+
run out, unless `auto_switch_to_pay_as_you_go` is false (section 3). Still non-custodial:
|
|
458
|
+
|
|
459
|
+
- Each wallet on an enabled chain gets a **receiver** (`GET /receivers`, `rcv_...`): a merchant-owned contract
|
|
460
|
+
(EVM, Tron) or program-owned vault (Solana) whose address is known before it is deployed. Invoices created afterwards set
|
|
461
|
+
`payment_options[].receiver_id` and `address` to the receiver. Customers pay it with ordinary transfers;
|
|
462
|
+
nothing changes for them or for your webhook handling.
|
|
463
|
+
- A **sweep** splits the receiver's balance on-chain: 99.6% to your wallet, 0.4% to CoinVello (or its backup
|
|
464
|
+
address; if neither can take it you get 100%). Nobody can change the split or redirect funds. CoinVello's
|
|
465
|
+
sweeper sweeps when the fee covers gas several times over; you can sweep any time with
|
|
466
|
+
`POST /receivers/{id}/sweep_transaction {"from":"0x..."}` (returns an unsigned EIP-1559 transaction; you pay its
|
|
467
|
+
gas). Every sweep is listed in `GET /sweeps` and fires `sweep.confirmed` (first one also `receiver.deployed`).
|
|
468
|
+
- Paid invoices show `fee: {"plan":"pay_as_you_go","bps":40,"amount":"...","asset":"usdc:base","receiver_id":"rcv_...","sweep_id":null}`;
|
|
469
|
+
`sweep_id` is set when the sweep that collected it confirms. Test mode: `POST /test/simulate_sweep {"receiver_id":"rcv_..."}`.
|
|
470
|
+
|
|
471
|
+
Do not hard-code receiver addresses; read them from the payment options. Do not add pay-as-you-go handling unless
|
|
472
|
+
the merchant asked for it: on prepaid nothing here applies.
|
|
473
|
+
|
|
474
|
+
## 3c. Contracts and safety (how to verify it)
|
|
475
|
+
|
|
476
|
+
Prepaid credits never involve a contract: payments go straight to the merchant's own wallet. Contracts exist
|
|
477
|
+
only for pay as you go.
|
|
478
|
+
|
|
479
|
+
- **One receiver per merchant wallet.** A contract (EVM, Tron) or a program-owned vault PDA (Solana) whose
|
|
480
|
+
address commits to (factory, merchant payout wallet, `feeRecipient`, `feeRecipientBackup`, `feeBps`).
|
|
481
|
+
Payers send a plain transfer. On EVM and Tron payments can arrive before deployment (counterfactual CREATE2
|
|
482
|
+
address); the receiver is deployed with its first sweep (`factory.deployAndSweep`). Solana vaults need no
|
|
483
|
+
deployment.
|
|
484
|
+
- **Sweeps are permissionless.** `sweep(token)` / `sweepNative()` / `sweepAll` (Solana: `sweep_sol` /
|
|
485
|
+
`sweep_token`) can be called by anyone and send `floor(balance x 40 / 10000)` to CoinVello's fee address and the rest (99.6%) to the merchant, in one
|
|
486
|
+
transaction. If the fee recipient cannot take the asset, the fee goes to the backup (`FeePaidToBackup`); if
|
|
487
|
+
that fails too, the merchant gets everything (`FeeForfeited`). The merchant leg has no fallback: if it
|
|
488
|
+
cannot be paid the sweep reverts and the funds stay in the receiver.
|
|
489
|
+
- **Nothing can be changed.** `payout`, `feeRecipient`, `feeRecipientBackup` and `feeBps` are immutable. No
|
|
490
|
+
owner, admin, pause, upgrade, setter, `selfdestruct` or `delegatecall`. Solidity 0.8.24 with OpenZeppelin
|
|
491
|
+
5.6.1 (SafeERC20, Math.mulDiv, ReentrancyGuard). Bitcoin has no contracts and uses credits.
|
|
492
|
+
- **Deployed (mainnet).**
|
|
493
|
+
- EVM `CoinVelloReceiverFactory` `0x4acA19Abe7a184a81c992dcA3c0432b892C98da7`, same address on Ethereum (1),
|
|
494
|
+
Base (8453), Arbitrum One (42161), OP Mainnet (10), Polygon (137), BNB Smart Chain (56) and Avalanche
|
|
495
|
+
C-Chain (43114), deployed through the deterministic CREATE2 deployer `0x4e59b44847b379578588920cA78FbF26c0B4956C`.
|
|
496
|
+
- Tron factory `TUGyMNRrd6DP5YLMy1P9HVqrJ9pFe4Ta2J`.
|
|
497
|
+
- Solana program `2ZxRKdxZxQ1FqKYhjdCd2iZTGznaNTx1ZuAfMF7ZysMr`, upgrade authority none (immutable); program
|
|
498
|
+
data sha256 `cd804af1e031b1141e608762b21233052a719785d14cd4ab9b77479db3852616` (verifiable build).
|
|
499
|
+
- **Fee addresses (multisigs).**
|
|
500
|
+
- EVM Safe: primary `0x02FDb629Dc017D3f115D82704cE34aD8b15773aC`, backup `0x9178411cDa785999a512f60434fdDaA3E054aa15`.
|
|
501
|
+
- Tron multisig: primary `TVxwn29oqnwL8ar7Ramh5htLDSnoeK2udt`, backup `TDzHd4THdZ6RU2BVq7z4mK4RGPMg7nntgt`.
|
|
502
|
+
- Solana Squads: primary `CHTNf9xF8fLj7YSAFvwyyuXw43gE4Nfg6Qa2sWCDpAvA`, backup `DWJ6THXUEXmrYBFb8Tx8ZHqddwcwdHgpcEZ1TFkjw4Ac`.
|
|
503
|
+
- **Check it yourself.** `GET /receivers` returns each receiver's `address`, `contract.address` (factory or
|
|
504
|
+
program), `fee_recipient`, `fee_recipient_backup` and `fee_bps`; compare them with the list above.
|
|
505
|
+
- **Audit status.** Not yet externally audited.
|
|
506
|
+
Sources: `contracts/`, `solana/`, `contracts/AUDIT.md` in the repository.
|
|
507
|
+
|
|
508
|
+
## 4. Other useful endpoints
|
|
509
|
+
|
|
510
|
+
```
|
|
511
|
+
GET /assets the assets of one mode (key's mode, else CoinVello-Livemode header,
|
|
512
|
+
default live): id, symbol, chain, kind, contract_address,
|
|
513
|
+
decimals, required_confirmations, min_amount, testnet, wallet_type
|
|
514
|
+
GET /rates?base=USD&assets=btc:bitcoin indicative mid-market rates
|
|
515
|
+
POST /rates/quotes, POST /rates/locks binding quote / lock (attach via invoice.rate_lock_id)
|
|
516
|
+
GET /payments?invoice_id=&status=&chain=&tx_hash= and GET /payments/{id}
|
|
517
|
+
GET /wallets, GET/PATCH/DELETE /wallets/{id}, GET /wallets/{id}/addresses,
|
|
518
|
+
POST /wallets/{id}/verification + POST /wallets/{id}/verify (optional ownership proof)
|
|
519
|
+
GET /webhooks, GET/PATCH/DELETE /webhooks/{id}, GET /webhooks/{id}/deliveries,
|
|
520
|
+
POST /webhooks/{id}/deliveries/{delivery_id}/retry
|
|
521
|
+
GET /events, GET /events/{id} backfill after an outage
|
|
522
|
+
POST /refunds, GET /refunds/{id}, POST /refunds/{id}/submit non-custodial: CoinVello prepares, you sign
|
|
523
|
+
POST /conversions/quotes, POST /conversions, POST /conversions/{id}/submit optional swaps to stablecoins
|
|
524
|
+
GET /offramp/providers, POST /offramp/accounts, POST /offramp/payouts optional fiat via your partner account
|
|
525
|
+
GET /records?occurred_gte=&occurred_lte=&type=¤cy= bookkeeping/tax book of record: one immutable row per movement
|
|
526
|
+
(payment received, refund sent, fee, credit purchase...), valued at the time
|
|
527
|
+
GET /records/summary?period_start=&period_end= sales, received (market value), refunds, fees, net for a period
|
|
528
|
+
POST /exports { dataset, format: csv|xlsx|json, preset, period } whole history or a date range; poll GET /exports/{id}
|
|
529
|
+
or listen for export.ready; presets quickbooks|xero|koinly|cointracker|coinledger
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
## 5. Errors
|
|
533
|
+
|
|
534
|
+
HTTP status + `error.type` + `error.code`. Branch on `type` first, then `code`.
|
|
535
|
+
|
|
536
|
+
```
|
|
537
|
+
400 invalid_request_error parameter_missing, parameter_invalid, parameter_unknown, amount_too_small, amount_too_large,
|
|
538
|
+
currency_unsupported, metadata_too_large
|
|
539
|
+
401 authentication_error api_key_missing, api_key_invalid, api_key_revoked, api_key_expired
|
|
540
|
+
402 credits_error insufficient_credits (details.available, details.required, details.bundles_url)
|
|
541
|
+
403 permission_error insufficient_scope, test_mode_only, live_mode_only, merchant_suspended, ip_not_allowed
|
|
542
|
+
404 invalid_request_error resource_missing
|
|
543
|
+
409 invalid_request_error invalid_state, quote_expired, rate_lock_expired, rate_lock_used
|
|
544
|
+
409 idempotency_error idempotency_key_reused (same key, different body), idempotency_key_in_progress (retry shortly)
|
|
545
|
+
422 invalid_request_error asset_not_enabled, xpub_required, xpub_invalid, xprv_rejected, address_invalid,
|
|
546
|
+
derivation_path_invalid, signature_invalid, challenge_expired, url_insecure, destination_invalid
|
|
547
|
+
429 rate_limit_error rate_limited (honour Retry-After)
|
|
548
|
+
5xx api_error / chain_error / partner_error retry with backoff
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
Example:
|
|
552
|
+
|
|
553
|
+
```json
|
|
554
|
+
{ "error": { "type": "invalid_request_error", "code": "parameter_invalid", "message": "amount must be a decimal string",
|
|
555
|
+
"param": "amount", "request_id": "req_7Hq2kLm9", "doc_url": "https://coinvello.com/docs/errors#parameter_invalid" } }
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
Retry policy: retry connection errors, `409 idempotency_key_in_progress`, `429` and `5xx` with
|
|
559
|
+
exponential backoff and the same `Idempotency-Key`. Do not retry 400/401/403/404/422.
|
|
560
|
+
|
|
561
|
+
## 6. Complete examples
|
|
562
|
+
|
|
563
|
+
### 6.1 curl in 60 seconds (works against the local mock too: B=http://localhost:4242/v1)
|
|
564
|
+
|
|
565
|
+
```bash
|
|
566
|
+
B=https://api.coinvello.com/v1; K="Authorization: Bearer $COINVELLO_API_KEY"; J="Content-Type: application/json"
|
|
567
|
+
# 1. webhook endpoint (store .secret)
|
|
568
|
+
curl -s $B/webhooks -H "$K" -H "$J" -d '{"url":"https://example.com/webhooks/coinvello","events":["*"]}'
|
|
569
|
+
# 2. checkout session -> send customer to .url
|
|
570
|
+
CS=$(curl -s $B/checkout-sessions -H "$K" -H "$J" -H "Idempotency-Key: demo-1" \
|
|
571
|
+
-d '{"amount":"49.99","currency":"USD","accepted_assets":["btc:bitcoin","usdt:tron"],"order_id":"DEMO-1","success_url":"https://example.com/thanks"}')
|
|
572
|
+
echo "$CS"; INV=$(echo "$CS" | sed -n 's/.*"invoice_id":"\([^"]*\)".*/\1/p')
|
|
573
|
+
# 3. simulate payment (test keys only)
|
|
574
|
+
curl -s $B/test/simulate_payment -H "$K" -H "$J" -d "{\"invoice_id\":\"$INV\",\"asset\":\"usdt:tron\",\"confirmations\":19}"
|
|
575
|
+
# 4. check
|
|
576
|
+
curl -s $B/invoices/$INV -H "$K"
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
### 6.2 Node (Express, no SDK)
|
|
580
|
+
|
|
581
|
+
```js
|
|
582
|
+
import express from "express";
|
|
583
|
+
import crypto from "node:crypto";
|
|
584
|
+
|
|
585
|
+
const API = process.env.COINVELLO_API_BASE ?? "https://api.coinvello.com/v1";
|
|
586
|
+
const KEY = process.env.COINVELLO_API_KEY; // cv_test_... or cv_live_...
|
|
587
|
+
const SECRET = process.env.COINVELLO_WEBHOOK_SECRET; // whsec_...
|
|
588
|
+
const app = express();
|
|
589
|
+
|
|
590
|
+
async function cv(method, path, body, idempotencyKey) {
|
|
591
|
+
const res = await fetch(API + path, {
|
|
592
|
+
method,
|
|
593
|
+
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json",
|
|
594
|
+
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}) },
|
|
595
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
596
|
+
});
|
|
597
|
+
const json = await res.json();
|
|
598
|
+
if (!res.ok) throw Object.assign(new Error(json.error?.message ?? res.statusText), { error: json.error, status: res.status });
|
|
599
|
+
return json;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// 1. start a payment: create a checkout session and redirect
|
|
603
|
+
app.post("/pay/:orderId", express.json(), async (req, res) => {
|
|
604
|
+
const order = await loadOrder(req.params.orderId); // your code
|
|
605
|
+
const session = await cv("POST", "/checkout-sessions", {
|
|
606
|
+
amount: order.total, // decimal string, e.g. "49.99"
|
|
607
|
+
currency: "USD",
|
|
608
|
+
accepted_assets: ["btc:bitcoin", "eth:ethereum", "usdc:polygon", "usdt:tron"],
|
|
609
|
+
order_id: order.id,
|
|
610
|
+
success_url: `https://shop.example.com/orders/${order.id}/thanks`,
|
|
611
|
+
cancel_url: `https://shop.example.com/orders/${order.id}`,
|
|
612
|
+
metadata: { order_id: String(order.id) },
|
|
613
|
+
}, `order-${order.id}-checkout`);
|
|
614
|
+
await saveCheckoutSession(order.id, session.id, session.invoice_id);
|
|
615
|
+
res.redirect(303, session.url);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
// 2. webhook: raw body, verify, dedupe, fulfil on invoice.paid
|
|
619
|
+
app.post("/webhooks/coinvello", express.raw({ type: "application/json" }), async (req, res) => {
|
|
620
|
+
let event;
|
|
621
|
+
try { event = verifyCoinVelloSignature(req.body, req.get("CoinVello-Signature"), SECRET); }
|
|
622
|
+
catch (e) { return res.status(400).send(`Webhook error: ${e.message}`); }
|
|
623
|
+
if (await alreadyProcessed(event.id)) return res.sendStatus(200);
|
|
624
|
+
const inv = event.data.object;
|
|
625
|
+
switch (event.type) {
|
|
626
|
+
case "invoice.paid": await markOrderPaid(inv.order_id, inv); break;
|
|
627
|
+
case "invoice.underpaid":
|
|
628
|
+
case "invoice.overpaid":
|
|
629
|
+
case "invoice.expired": await flagOrder(inv.order_id, event.type); break;
|
|
630
|
+
}
|
|
631
|
+
await rememberProcessed(event.id);
|
|
632
|
+
res.sendStatus(200);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
function verifyCoinVelloSignature(rawBody, header, secret, toleranceSec = 300) { /* section 2.5 */ }
|
|
636
|
+
app.listen(3000);
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
With the SDK (`npm install @coinvello/sdk`): `import { CoinVello } from "@coinvello/sdk"; const cv = new CoinVello(KEY);`
|
|
640
|
+
`await cv.checkoutSessions.create({...}, { idempotencyKey })`, `cv.webhooks.constructEvent(raw, header, secret)`.
|
|
641
|
+
|
|
642
|
+
### 6.3 Python (FastAPI, no SDK)
|
|
643
|
+
|
|
644
|
+
```python
|
|
645
|
+
import os, hmac, hashlib, json, time
|
|
646
|
+
import httpx
|
|
647
|
+
from fastapi import FastAPI, Request, HTTPException
|
|
648
|
+
from fastapi.responses import RedirectResponse
|
|
649
|
+
|
|
650
|
+
API = os.environ.get("COINVELLO_API_BASE", "https://api.coinvello.com/v1")
|
|
651
|
+
KEY = os.environ["COINVELLO_API_KEY"]
|
|
652
|
+
SECRET = os.environ["COINVELLO_WEBHOOK_SECRET"]
|
|
653
|
+
app = FastAPI()
|
|
654
|
+
|
|
655
|
+
async def cv(method: str, path: str, body: dict | None = None, idempotency_key: str | None = None) -> dict:
|
|
656
|
+
headers = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}
|
|
657
|
+
if idempotency_key:
|
|
658
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
659
|
+
async with httpx.AsyncClient(timeout=20) as client:
|
|
660
|
+
r = await client.request(method, API + path, json=body, headers=headers)
|
|
661
|
+
data = r.json()
|
|
662
|
+
if r.status_code >= 400:
|
|
663
|
+
raise HTTPException(502, data.get("error", {}).get("message", "CoinVello error"))
|
|
664
|
+
return data
|
|
665
|
+
|
|
666
|
+
@app.post("/pay/{order_id}")
|
|
667
|
+
async def pay(order_id: str):
|
|
668
|
+
order = load_order(order_id) # your code; order["total"] is a decimal string like "49.99"
|
|
669
|
+
session = await cv("POST", "/checkout-sessions", {
|
|
670
|
+
"amount": order["total"], "currency": "USD",
|
|
671
|
+
"accepted_assets": ["btc:bitcoin", "eth:ethereum", "usdc:polygon", "usdt:tron"],
|
|
672
|
+
"order_id": order_id,
|
|
673
|
+
"success_url": f"https://shop.example.com/orders/{order_id}/thanks",
|
|
674
|
+
"cancel_url": f"https://shop.example.com/orders/{order_id}",
|
|
675
|
+
"metadata": {"order_id": order_id},
|
|
676
|
+
}, idempotency_key=f"order-{order_id}-checkout")
|
|
677
|
+
save_checkout_session(order_id, session["id"], session["invoice_id"])
|
|
678
|
+
return RedirectResponse(session["url"], status_code=303)
|
|
679
|
+
|
|
680
|
+
@app.post("/webhooks/coinvello")
|
|
681
|
+
async def webhook(request: Request):
|
|
682
|
+
raw = await request.body()
|
|
683
|
+
try:
|
|
684
|
+
event = verify_coinvello_signature(raw, request.headers.get("CoinVello-Signature", ""), SECRET)
|
|
685
|
+
except ValueError as e:
|
|
686
|
+
raise HTTPException(400, str(e))
|
|
687
|
+
if already_processed(event["id"]):
|
|
688
|
+
return {"ok": True}
|
|
689
|
+
inv = event["data"]["object"]
|
|
690
|
+
if event["type"] == "invoice.paid":
|
|
691
|
+
mark_order_paid(inv["order_id"], inv)
|
|
692
|
+
elif event["type"] in ("invoice.underpaid", "invoice.overpaid", "invoice.expired"):
|
|
693
|
+
flag_order(inv["order_id"], event["type"])
|
|
694
|
+
remember_processed(event["id"])
|
|
695
|
+
return {"ok": True}
|
|
696
|
+
|
|
697
|
+
def verify_coinvello_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> dict:
|
|
698
|
+
... # section 2.5
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
With the SDK (`pip install https://coinvello.com/downloads/sdk/coinvello-0.1.0-py3-none-any.whl`): `from coinvello import CoinVello; cv = CoinVello(KEY)`; `cv.checkout_sessions.create({...}, idempotency_key=...)`;
|
|
702
|
+
`coinvello.Webhooks.construct_event(raw, header, secret)`.
|
|
703
|
+
|
|
704
|
+
### 6.4 PHP (plain; same logic applies to Laravel / WordPress)
|
|
705
|
+
|
|
706
|
+
```php
|
|
707
|
+
<?php
|
|
708
|
+
// pay.php - create a checkout session and redirect
|
|
709
|
+
$api = getenv('COINVELLO_API_BASE') ?: 'https://api.coinvello.com/v1';
|
|
710
|
+
$key = getenv('COINVELLO_API_KEY');
|
|
711
|
+
|
|
712
|
+
function cv_request(string $method, string $path, ?array $body = null, ?string $idem = null): array {
|
|
713
|
+
global $api, $key;
|
|
714
|
+
$headers = ['Authorization: Bearer ' . $key, 'Content-Type: application/json'];
|
|
715
|
+
if ($idem) { $headers[] = 'Idempotency-Key: ' . $idem; }
|
|
716
|
+
$ch = curl_init($api . $path);
|
|
717
|
+
curl_setopt_array($ch, [
|
|
718
|
+
CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true,
|
|
719
|
+
CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body), CURLOPT_TIMEOUT => 20,
|
|
720
|
+
]);
|
|
721
|
+
$raw = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch);
|
|
722
|
+
$json = json_decode((string) $raw, true) ?? [];
|
|
723
|
+
if ($status >= 400) { throw new RuntimeException($json['error']['message'] ?? 'CoinVello error', $status); }
|
|
724
|
+
return $json;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
$order = load_order($_GET['order_id']); // your code
|
|
728
|
+
$session = cv_request('POST', '/checkout-sessions', [
|
|
729
|
+
'amount' => $order['total'], // decimal string "49.99"
|
|
730
|
+
'currency' => 'USD',
|
|
731
|
+
'accepted_assets' => ['btc:bitcoin', 'eth:ethereum', 'usdc:polygon', 'usdt:tron'],
|
|
732
|
+
'order_id' => (string) $order['id'],
|
|
733
|
+
'success_url' => 'https://shop.example.com/orders/' . $order['id'] . '/thanks',
|
|
734
|
+
'cancel_url' => 'https://shop.example.com/orders/' . $order['id'],
|
|
735
|
+
'metadata' => ['order_id' => (string) $order['id']],
|
|
736
|
+
], 'order-' . $order['id'] . '-checkout');
|
|
737
|
+
save_checkout_session($order['id'], $session['id'], $session['invoice_id']);
|
|
738
|
+
header('Location: ' . $session['url'], true, 303);
|
|
739
|
+
exit;
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
```php
|
|
743
|
+
<?php
|
|
744
|
+
// webhook.php - raw body, verify (function from section 2.5), dedupe, fulfil
|
|
745
|
+
$raw = file_get_contents('php://input'); // never $_POST
|
|
746
|
+
$header = $_SERVER['HTTP_COINVELLO_SIGNATURE'] ?? '';
|
|
747
|
+
try {
|
|
748
|
+
$event = coinvello_verify($raw, $header, getenv('COINVELLO_WEBHOOK_SECRET'));
|
|
749
|
+
} catch (Throwable $e) {
|
|
750
|
+
http_response_code(400); exit('Webhook error: ' . $e->getMessage());
|
|
751
|
+
}
|
|
752
|
+
if (already_processed($event['id'])) { http_response_code(200); exit; }
|
|
753
|
+
$inv = $event['data']['object'];
|
|
754
|
+
if ($event['type'] === 'invoice.paid') {
|
|
755
|
+
mark_order_paid($inv['order_id'], $inv);
|
|
756
|
+
} elseif (in_array($event['type'], ['invoice.underpaid', 'invoice.overpaid', 'invoice.expired'], true)) {
|
|
757
|
+
flag_order($inv['order_id'], $event['type']);
|
|
758
|
+
}
|
|
759
|
+
remember_processed($event['id']);
|
|
760
|
+
http_response_code(200);
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
With the SDK (`composer config repositories.coinvello composer https://coinvello.com/composer && composer require coinvello/sdk`): `$cv = new \CoinVello\Sdk\Client($key);`
|
|
764
|
+
`$cv->checkoutSessions->create([...], ['idempotency_key' => ...]);` `\CoinVello\Sdk\Webhooks::constructEvent($raw, $header, $secret)`.
|
|
765
|
+
|
|
766
|
+
## 7. Embedded checkout and plugins (when not to write API code)
|
|
767
|
+
|
|
768
|
+
- **Embeddable widget** `@coinvello/checkout`: your server creates the session; the browser calls
|
|
769
|
+
`CoinVelloCheckout.redirect(sessionId)`, `.open(sessionId)` (modal) or `.mount(el, sessionId)`
|
|
770
|
+
(inline), or uses `<coinvello-pay-button session="cs_...">`. The widget only needs the session id;
|
|
771
|
+
it talks to the public endpoints and never sees your API key.
|
|
772
|
+
- **Plugins**: WooCommerce is available now (https://coinvello.com/integrations/woocommerce, zip download
|
|
773
|
+
with SHA-256); Shopify is pending Shopify Payments approval; Magento 2, PrestaShop and OpenCart are in
|
|
774
|
+
testing and not yet available. If the merchant runs WooCommerce, install the plugin and paste the API
|
|
775
|
+
key; do not write custom code. For the others, use the API or SDKs until the plugin ships.
|
|
776
|
+
|
|
777
|
+
## 8. Go-live checklist
|
|
778
|
+
|
|
779
|
+
1. Live wallets registered with a `cv_live_` key (verification optional; for xpubs, `first_address`
|
|
780
|
+
matches the wallet's first receive address).
|
|
781
|
+
2. Credits bought (`GET /credits/bundles`, `POST /credits/purchases`, pay the option).
|
|
782
|
+
3. Live webhook endpoint over HTTPS created; secret stored; handler verifies signature, dedupes on
|
|
783
|
+
`event.id`, responds 2xx in < 10 s, and fulfils only on `invoice.paid`.
|
|
784
|
+
4. `Idempotency-Key` set on every POST; amounts are strings; API key only on the server.
|
|
785
|
+
5. Swap `cv_test_` for `cv_live_`. There is no account review step.
|
|
786
|
+
|
|
787
|
+
## 9. Links
|
|
788
|
+
|
|
789
|
+
- Website https://coinvello.com · Build with AI https://coinvello.com/ai · Dashboard https://coinvello.com/app
|
|
790
|
+
- OpenAPI https://coinvello.com/.well-known/openapi.yaml · MCP descriptor https://coinvello.com/.well-known/mcp.json
|
|
791
|
+
- Plugins https://coinvello.com/integrations (WooCommerce zip: https://coinvello.com/downloads/coinvello-for-woocommerce-1.0.4.zip) · Developer docs https://coinvello.com/developers · llms.txt index https://coinvello.com/llms.txt
|
|
792
|
+
- Support support@coinvello.com · Status https://status.coinvello.com
|