@coinvello/mcp 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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`, `coinvello/sdk`, `coinvello`; hosted at
38
+ https://coinvello.com/downloads/sdk/ until the npm, 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=&currency= 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 https://coinvello.com/downloads/sdk/coinvello-sdk-0.1.0.tgz`): `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