Accept payments on your website — SCI
The buyer goes to the payment page, pays from any wallet, and your server receives a signed notification with the result. This page covers everything you need to connect.
Contents
How it works
- The buyer clicks “Pay” on your website, and the browser sends a signed form to the payment page.
- An invoice is created: the buyer picks a coin and sees the amount, address, QR code and timer.
- The buyer sends the coins. We wait for network confirmations and check the amount, coin and time.
- Your server receives a notification with the result, and the buyer returns to your website.
Setup step by step
- In your account: Projects → Add project, with a name and website address. You get a shop ID, a form key (sk_…) and a webhook secret (whsec_…). Keys are shown once — store them on your shop server.
- Coins — turn on the coins and networks you accept.
- Payment page — invoice lifetime, amount tolerance, language, return addresses and support email for buyers.
- Notifications — webhook address and “Send test”.
- Connection — verify your domain and switch the project to Test mode.
- Check your side with test invoices, then switch the project to Live.
Payment form
Build the form on your server: the buyer’s browser posts the fields to the payment page address. The form key never reaches the browser — the form only carries the signature.
<form method="post" action="https://sci.coinvertus.net">
<input type="hidden" name="shop" value="P4K7Q2A9">
<input type="hidden" name="order_id" value="10482">
<input type="hidden" name="amount" value="49.90">
<input type="hidden" name="currency" value="USD">
<input type="hidden" name="sign" value="<?= $sign ?>">
<input type="hidden" name="success_url" value="https://shop.example/orders/10482">
<button type="submit">Pay with crypto</button>
</form>Form fields
| Field | Description |
|---|---|
shopRequired | Shop ID from your account, 8 characters. |
order_idRequired | Your order number: 1–64 characters, no spaces. Repeating it with the same amount and currency opens the same invoice; with different ones — error S005. |
amountRequired | Amount with a dot and up to two decimals: 49.90, 49.9 or 50. Greater than zero. |
currencyRequired | Amount currency: USD, EUR, RUB, CNY or INR. The coin amount is calculated at the rate when the buyer picks a coin. |
signRequired | Form signature — see below. |
asset | Coin chosen for the buyer, as symbol:network — like asset and network in the notification, e.g. DOGE:dogecoin-mainnet or USDT:bsc. If the coin is enabled in the project, the buyer goes straight to the payment details. |
description | Order description up to 255 characters, shown to the buyer on the payment page. |
email | Buyer email, up to 254 characters. |
lang | Page language: ru, en or cn. Without it — the project language setting, otherwise the browser language. The buyer can switch the language. |
success_url | Where to send the buyer after payment, an http(s) address. Without it — the project setting, otherwise your website. |
fail_url | Where to send the buyer if there was no payment or it did not match. Without it — the project setting, otherwise your website. |
metadata | A JSON object or array up to 4 KB, returned in the notification as is. |
Signature
sign is the hex HMAC-SHA256 of the string shop:order_id:amount:currency, keyed with the project form key. Values are used exactly as sent in the form: 49.90 and 49.9 give different signatures.
$shop = 'P4K7Q2A9';
$formKey = getenv('CVC_SCI_FORM_KEY');
$orderId = '10482';
$amount = '49.90';
$currency = 'USD';
$sign = hash_hmac('sha256', $shop . ':' . $orderId . ':' . $amount . ':' . $currency, $formKey);Form errors
If the form fails the checks, the buyer sees a page with the error code and a link back to your website.
| Error code | Meaning |
|---|---|
S002 | A field failed validation — its name is in the error text. |
S003 | The signature does not match. |
S004 | Shop not found or not accepting payments: the project is a draft, is live without a verified domain, or the plan has no payment acceptance. |
S005 | An invoice for this order_id is already open with a different amount or currency. |
S006 | Internal error while creating the invoice — try again later. |
S007 | Too many new invoices: at most 300 per hour per project and 30 per hour from one buyer address. |
Invoice and statuses
An invoice lives 15 to 120 minutes, set on the Payment page tab. The coin amount is fixed when the buyer picks a coin; the coin can be changed up to three times. The number of confirmations is set by the network.
| Status | Description |
|---|---|
new | Invoice created, no coin chosen yet. |
waiting | Coin chosen, waiting for the transfer. |
confirming | The transfer is in the network, waiting for confirmations. |
partial | Part of the amount arrived, waiting for the rest — at least 15 minutes from the first part. |
paid | Paid: amount within tolerance, on time, correct coin. |
received | Money arrived, but something did not match — see warnings. |
expired | Expired with no transfer. A later transfer of exactly the amount is still counted: the invoice becomes received with incorrect time. |
cancelled | The shop cancelled the invoice through the API before payment. |
Return to the shop
After payment the Continue button leads to success_url, otherwise to fail_url. The return proves nothing: the buyer can open that address directly. Change the order status only on a notification.
Shop server API
The form fits when the buyer’s browser opens the invoice. If your server places the order, create the invoice through the API and send the buyer to the url from the response. The API also lets you check an invoice if a notification did not arrive, cancel an unpaid invoice and get the list of coins with amounts.
| Method and URL | Description |
|---|---|
POST https://sci.coinvertus.net/api/v1/invoices | Create an invoice. |
GET https://sci.coinvertus.net/api/v1/invoices/{number} | An invoice by its number, e.g. CVC-7F3K2A. |
GET https://sci.coinvertus.net/api/v1/invoices?order_id=… | An invoice by your order number. |
POST https://sci.coinvertus.net/api/v1/invoices/{number}/cancel | Cancel an invoice while it has no transfer. |
GET https://sci.coinvertus.net/api/v1/assets?currency=USD&amount=49.90 | Coins the payment page offers right now, with rate and amount. |
Request signature
Every request is signed with the project form key — the same one the form uses; the key itself is never sent. v1 is the hex HMAC-SHA256 of the string “t.METHOD.path.body”: path starts at /v1/ and includes the query string exactly as you send it; body is the exact bytes sent in the request, an empty string for GET. A request whose t differs from our clock by more than 5 minutes is rejected.
| Header | Description |
|---|---|
X-CVC-Shop | Shop ID — the same value as the shop form field. |
X-CVC-Signature | Signature: t=<Unix time>,v1=<signature>. |
<?php
function cvcSignature(string $method, string $path, string $body): string
{
$t = time();
$v1 = hash_hmac('sha256', $t . '.' . $method . '.' . $path . '.' . $body, getenv('CVC_SCI_FORM_KEY'));
return 't=' . $t . ',v1=' . $v1;
}
$body = json_encode([
'order_id' => '10482',
'amount' => '49.90',
'currency' => 'USD',
'success_url' => 'https://shop.example/orders/10482',
]);
$response = $http->request('POST', 'https://sci.coinvertus.net/api/v1/invoices', $body, [
'Content-Type: application/json',
'X-CVC-Shop: P4K7Q2A9',
'X-CVC-Signature: ' . cvcSignature('POST', '/v1/invoices', $body),
]);
if ($response->status === 200 || $response->status === 201) {
header('Location: ' . json_decode($response->body, true)['url'], true, 303);
}
$path = '/v1/invoices?order_id=' . rawurlencode('10482');
$response = $http->request('GET', 'https://sci.coinvertus.net/api' . $path, '', [
'X-CVC-Shop: P4K7Q2A9',
'X-CVC-Signature: ' . cvcSignature('GET', $path, ''),
]);$http is your HTTP client: it sends the request with these headers and returns the response status and body.
Creating an invoice
The body is JSON with the same fields as the form, without shop and sign: order_id, amount and currency are required, the rest are optional. amount and order_id may be numbers, metadata may be an object. A new invoice is returned with status 201; repeating the same order_id with the same amount and currency returns the same invoice with 200. The limit is 300 new invoices per hour per project.
{
"test": false,
"validation": null,
"warnings": [],
"invoice": {
"number": "CVC-7F3K2A",
"order_id": "10482",
"status": "new",
"amount": "49.90",
"currency": "USD",
"asset": null,
"network": null,
"rate": null,
"amount_crypto": null,
"received_crypto": null,
"remaining_crypto": null,
"overpaid_crypto": null,
"expires_at": "2026-09-17T12:22:00Z",
"metadata": {
"cart": "a17"
}
},
"settlement": {
"mode": "direct",
"fee_percent": "0.00"
},
"url": "https://sci.coinvertus.net/pY3kQ9vT2mXc8LwR1nZb4A"
}The response is the invoice in the same fields as the notification: invoice, settlement and test. validation and warnings are the current outcome of the invoice: null and an empty list until money arrives. url is the payment page — send the buyer there. Invoice checks and cancellation respond the same way.
Cancelling an invoice
Only an invoice without money can be cancelled: status new or waiting and no transfer, not even an unconfirmed one. The invoice gets status cancelled and your server receives invoice.cancelled; cancelling again returns the same invoice. If the buyer still sends exactly the invoice amount, the transfer is counted: payment.received arrives with validation false; any other amount gives no notification.
List of coins
Coins the payment page will offer right now: enabled in the project and with a current rate. currency is the rate currency, USD by default; with amount the response also has the amount in the coin, as the invoice would calculate it. asset and network fit the asset field: DOGE:dogecoin-mainnet.
API errors
An error is an HTTP status and the body {"error": {"code": "S002", "message": "…"}}; S002 also has fields — the names of invalid fields. message is for people, in the language from Accept-Language; rely on code in your code.
| HTTP status | Error code | Meaning |
|---|---|---|
400 | S002 | Invalid field or request body; field names are in error.fields. |
401 | S003 | Unknown X-CVC-Shop, invalid or expired signature. |
403 | S004 | The project does not accept payments: it is a draft, is live without a verified domain, or the plan has no payment acceptance. Only when creating an invoice. |
404 | S008 | No invoice with this number or order_id in the project. |
409 | S005 | An invoice for this order_id is already open with a different amount or currency. |
409 | S009 | Cannot cancel: the invoice already has a transfer or its status is neither new nor waiting. |
429 | S007 | More than 300 new invoices per hour in the project. |
500 | S006 | Internal error while creating the invoice — try again later. |
Notifications (webhooks)
Set the address on the Notifications tab: an https address and, if needed, the server IP and Host. Changing it requires a confirmation code from an authenticator app or an email. “Send test” sends a ping; the project cannot go Live without a delivered ping.
A notification is a POST with a JSON body. Any 2xx response within 10 seconds is a success. Otherwise we retry after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours. Redirects are not followed; at most 64 KB of the response is read.
| Header | Description |
|---|---|
X-CVC-Event | Event name, same as event in the body. |
X-CVC-Delivery | event_id and attempt number. |
X-CVC-Signature | Signature: t=<Unix time>,v1=<signature>. |
v1 is the hex HMAC-SHA256 of the string “t.body”, keyed with the project webhook secret. Use the body exactly as received, before parsing JSON, and reject the notification if t differs from your clock by more than 5 minutes.
The same notification may arrive twice — handle each event_id once. Delivery order is not guaranteed: do not roll back a paid order because of an earlier notification.
Notification events
| Event | When it is sent |
|---|---|
payment.received | A transfer to the invoice address got its confirmations. Carries validation and warnings. Each transaction output gets its own notification. |
invoice.expired | The invoice expired: no transfer (status expired) or not fully paid (status received). |
invoice.cancelled | The invoice was cancelled through the API. |
payment.reversed | A counted transfer dropped out of the chain after a block reorganization — we handle it on our side. |
payment.restored | The block with the transfer is back in the chain; the payment is not counted twice. |
ping | Test notification from your account. |
All events except ping can be turned off with checkboxes on the Notifications tab.
Notification body
Amounts are strings with a dot and no trailing zeros. Until a coin is chosen, coin fields are null. Times are in UTC.
{
"event": "payment.received",
"event_id": "evt_0123456789abcdef01234567",
"created_at": "2026-09-17T12:10:00Z",
"test": false,
"validation": true,
"warnings": [],
"invoice": {
"number": "CVC-7F3K2A",
"order_id": "10482",
"status": "paid",
"amount": "49.90",
"currency": "USD",
"asset": "DOGE",
"network": "dogecoin-mainnet",
"rate": "0.275",
"amount_crypto": "181.45",
"received_crypto": "181.45",
"remaining_crypto": "0",
"overpaid_crypto": "0",
"expires_at": "2026-09-17T12:22:00Z",
"metadata": {
"cart": "a17"
}
},
"payment": {
"txid": "c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0",
"output": 1,
"asset": "DOGE",
"network": "dogecoin-mainnet",
"amount_crypto": "181.45",
"confirmations": 6,
"seen_at": "2026-09-17T11:59:02Z"
},
"settlement": {
"mode": "direct",
"fee_percent": "0.00"
}
}| Field | Description |
|---|---|
test | true — the invoice was created in Test mode. |
validation, warnings | Payment check result, only in payment.received. |
invoice.number | Invoice number shown on the payment page. |
invoice.status | Invoice status after the event. |
invoice.amount_crypto, received_crypto, remaining_crypto, overpaid_crypto | Invoice amount in the coin, received, left to pay, overpaid. |
invoice.rate | Coin rate to the invoice currency when the coin was chosen. |
invoice.metadata | metadata from the form, as is. |
payment.output | Output number: for UTXO networks, the transaction output to the invoice address (one transaction may have several); for EVM, 0 is a direct coin transfer, 1 onwards are coin transfers from a contract within the same transaction, and a token transfer is 1000000 plus its log index in the block (logIndex). EVM txids start with 0x. TRON works the same way, but internal coin transfers are numbered from 1. For Solana, it is the transfer number within the transaction, and 1000000 plus it for a token; the txid is the transaction signature. For XRP, it is 0 and the txid is the transaction hash; the invoice is identified by its destination tag. For TON, it is 0 and the txid is the hash of the project address transaction; the invoice is identified by the transfer comment. For a TON jetton, it is 1000000 plus the event number in the transaction, and the txid is the hash of the jetton wallet transaction. For an XRPL token, it is 1000000 and the txid is the transaction hash; the invoice is also identified by its destination tag. |
payment.seen_at | When the transfer appeared in the network — the deadline is checked against this time. |
settlement | Where the money goes: direct — straight to your wallet, pool — through the pool with the fee_percent fee. |
validation and warnings
Ship the order only when validation is true. With validation false the money is still credited to the shop — decide yourself: ship, refund the difference or contact the buyer.
| Value in warnings | Meaning |
|---|---|
incorrect amount | Amount outside tolerance: underpaid, overpaid or a top-up to an already closed invoice. |
incorrect network or token | The wrong coin or network arrived. |
incorrect time | The transfer appeared in the network after the invoice deadline. |
| Situation | Status | validation | warnings |
|---|---|---|---|
| Amount within tolerance, on time, correct coin | paid | true | — |
| Underpaid, time still running | partial | false | incorrect amount |
| Paid the rest in time | paid | true | — |
| Underpaid, top-up time is over | received | false | incorrect amount |
| Overpaid beyond tolerance | received | false | incorrect amount |
| Same coin on another network, exactly the amount due | received | false | incorrect network or token |
| Transfer after the deadline, exactly the amount due | received | false | incorrect time |
| Another coin, a zero transfer, any transfer to a paid invoice, after the deadline — not exactly the amount due | unchanged, no notification | — | — |
Several transfers to one invoice add up; the amount tolerance is set on the Payment page tab.
Transfers the invoice does not expect are not payments: there is no notification and the status does not change; with pool settlement such money is not paid out to the shop. This is how address-poisoning spam reaches invoice addresses. If a buyer sent the wrong coin or paid twice, contact CVC support with the invoice number and the transaction.
Test mode and simulation
In Test mode invoices are marked test: true, and the payment page accepts the same coins as in Live mode. At the bottom of a test invoice page there is Payment simulation: pay exactly, pay part or overpay, expire the invoice, roll a block back and restore it — without a transfer. The invoice goes the same way as with a real payment, and the shop receives the same notifications with test: true.
Domain verification
To go Live, prove the site is yours: put a cvc-sci.txt file with the line from the Connection tab in your site root and click Check. If the domain does not point to your server yet, enter the server IP — the check will go there.
Own domain
On plans with an own domain the buyer pays on your address, for example pay.example.com, and never sees the CoinVertus address. Your server (facade) stands in front of the payment page: it holds your certificate and proxies the payment page to sci.coinvertus.net. The server and the certificate are yours.
- Point the domain to your server and issue a certificate for it.
- Configure nginx like the example below: the server serves /cvc-sci.txt, your styles and the icon itself, proxies the payment page paths to sci.coinvertus.net and answers 404 to everything else.
- In the project Connection tab enter the domain and the IP addresses your servers use to reach sci.coinvertus.net. Changes require a confirmation code.
- Serve the same line in /cvc-sci.txt as for the project site and click Check.
- After verification links to new invoices, from the form and from the API, lead to your domain. The payment form can be posted to the root of your domain. If the shop site sets a CSP form-action, add your payment domain to it: after the form the buyer is redirected there.
nginx example for your server:
server {
listen 443 ssl;
server_name pay.example.com;
ssl_certificate /etc/ssl/pay.example.com/fullchain.pem;
ssl_certificate_key /etc/ssl/pay.example.com/privkey.pem;
location = /cvc-sci.txt {
default_type text/plain;
return 200 "cvc-sci=P4K7Q2A9-0123456789abcdef0123456789abcdef\n";
}
location = /cvc-sci.css {
root /srv/pay;
}
location = /favicon.ico {
root /srv/pay;
}
location ~ "^/(?:$|[A-Za-z0-9_-]{22}$|sci/|api/v1/|language$|css/|js/|fonts/|webfonts/|images/|coin-icon/)" {
proxy_pass https://sci.coinvertus.net;
proxy_set_header Host sci.coinvertus.net;
proxy_ssl_server_name on;
proxy_ssl_name sci.coinvertus.net;
proxy_redirect https://sci.coinvertus.net/ /;
}
location / {
return 404;
}
}Page styling
On your domain the payment page loads /cvc-sci.css after our styles. Your server serves this file; an empty file keeps the default look. The CoinVertus logo, icon and name are not shown on your domain. Rely on the classes in the table: the rest of the markup may change.
| Selector | Description |
|---|---|
.cvc-sci-own | The whole page on your domain: background, font, the main colour via the --cvc-color-primary variable. |
.cvc-sci-bar | Top bar with the language switch. |
.cvc-sci-brand | Place for your logo on the left of the bar: an empty block, give it a size and a background. |
.cvc-sci-column | Centre column: width of the bar and the card. |
.cvc-sci-card | Invoice card. |
.cvc-sci-shop-avatar | First letter of the shop name in the invoice header. |
.cvc-sci-coin-box .cvc-sci-asset-icon | Selected coin and coin and network icons. |
.cvc-sci-copy-value .cvc-sci-address | Amount, address and other details the buyer copies. |
.cvc-sci-qr | Payment QR code. |
.btn-login | Main buttons: Pay from wallet, Continue. |
.cvc-sci-action | Secondary buttons: receipt, message to the shop. |
.cvc-sci-waiting .cvc-sci-found .cvc-sci-outcome-icon | Waiting for payment, found payment and the outcome icon. |
.cvc-sci-progress | Confirmations and top-up progress bar. |
.cvc-sci-rows | Payment detail rows on the outcome screen. |
Example /cvc-sci.css:
.cvc-sci-own {
--cvc-color-primary: #1d4ed8;
background: #f4f1ea;
}
.cvc-sci-brand {
width: 140px;
height: 40px;
background: url(/logo.svg) no-repeat left center / contain;
}
.cvc-sci-card {
border-radius: 4px;
}The page security policy allows styles only from files on your domain, images and fonts only from your domain or data:. External CDNs will not load.
PHP example
Signature and form:
<?php
$shop = 'P4K7Q2A9';
$formKey = getenv('CVC_SCI_FORM_KEY');
$orderId = '10482';
$amount = '49.90';
$currency = 'USD';
$sign = hash_hmac('sha256', $shop . ':' . $orderId . ':' . $amount . ':' . $currency, $formKey);
?>
<form method="post" action="https://sci.coinvertus.net">
<input type="hidden" name="shop" value="P4K7Q2A9">
<input type="hidden" name="order_id" value="10482">
<input type="hidden" name="amount" value="49.90">
<input type="hidden" name="currency" value="USD">
<input type="hidden" name="sign" value="<?= $sign ?>">
<input type="hidden" name="success_url" value="https://shop.example/orders/10482">
<button type="submit">Pay with crypto</button>
</form>Receiving a notification:
<?php
$secret = getenv('CVC_SCI_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$t = null;
$signatures = [];
foreach (explode(',', $_SERVER['HTTP_X_CVC_SIGNATURE'] ?? '') as $pair) {
[$name, $value] = array_pad(explode('=', trim($pair), 2), 2, '');
if ($name === 't') {
$t = $value;
} elseif ($name === 'v1') {
$signatures[] = strtolower($value);
}
}
$expected = hash_hmac('sha256', $t . '.' . $body, $secret);
$valid = $t !== null && ctype_digit($t) && abs(time() - (int) $t) <= 300
&& array_filter($signatures, fn (string $v1): bool => hash_equals($expected, $v1)) !== [];
if (!$valid) {
http_response_code(400);
exit;
}
$hook = json_decode($body, true);
if (($hook['test'] && $liveMode) || !$events->remember($hook['event_id'])) {
http_response_code(200);
exit;
}
$orderId = $hook['invoice']['order_id'];
switch ($hook['event']) {
case 'payment.received':
$hook['validation']
? $orders->markPaid($orderId)
: $orders->markForReview($orderId, $hook['warnings']);
break;
case 'invoice.expired':
$hook['invoice']['status'] === 'expired'
? $orders->cancel($orderId)
: $orders->markForReview($orderId, ['incorrect amount']);
break;
case 'payment.reversed':
$orders->markForReview($orderId, ['reversed']);
break;
}
http_response_code(200);$orders, $events and $liveMode are your order storage, the log of handled event_id values and the Live mode flag. Answer 200 only after the notification is handled.