CryptoLink Documentation v1.0

CryptoLink is a 100% open-source, self-hosted, non-custodial crypto payment gateway. Accept Bitcoin, Ethereum, USDT, USDC, TRON, and more — on your own server, with your own keys. No third party ever touches your funds. No per-transaction fees. No lock-in.

Current release: CryptoLink v1.0. See the Changelog for what shipped in this release.

0%
Transaction Fees
17
Currencies / 7 Chains
MIT
License
$0
Starting Plan

What is CryptoLink?

CryptoLink gives merchants complete sovereignty over their crypto payment infrastructure. Unlike centralized processors (Stripe, Coinbase Commerce, BitPay), CryptoLink runs entirely on your own server. You generate your own wallet addresses, you receive funds directly, and your private keys never leave your custody.

CryptoLink is written in Go (backend) and React + Vite (frontend), with a clean REST API and two independent dashboards — one for merchants, one for the super-admin operator. It connects directly to blockchain networks — Blockstream/mempool.space for Bitcoin, go-ethereum RPC for EVM chains, and TronGrid for TRON — with no third-party middleware standing between you and the blockchain.

Key Features

Subscription Plans

PlanPriceMonthly VolumeMerchants
Free$0/moUp to $1,0001
Starter$9.99/moUp to $10,0001
Growth$29.99/moUp to $50,0003
Business$79.99/moUp to $250,00010
Enterprise$199.99/moUnlimitedUnlimited
Open Source

CryptoLink is fully open source under the MIT license. Every line of backend Go code, frontend React component, database migration, and Solidity contract is available for inspection and audit.

Anonymity & What Goes On-Chain

CryptoLink is built for privacy-conscious operators. Here is exactly what is and isn't visible to whom:

What is NEVER published or known to CryptoLink

What IS publicly visible (because all blockchains are public ledgers)

Are the contracts safe to publish?

Yes — and they are intentionally public. There are no hardcoded admin or merchant addresses in any of them. The factory has no admin powers over deployed clones. The owner of each clone is set once at deploy time (passed in by the deploying merchant) and cannot be changed. CryptoLink does not publish the addresses of any specific deployed factory — each operator is expected to deploy their own per chain.

How It Works

CryptoLink follows a simple, transparent flow. For Bitcoin, every payment gets a unique address derived from your xpub key. For EVM chains and TRON, payments go to your merchant-owned smart contract collector — no shared wallets, no commingled funds.

Payment Flow

1
Merchant creates a payment via API Your server calls POST /api/merchant/v1/merchant/{merchantId}/payment with fiat currency (USD/EUR), amount, and your order ID. CryptoLink returns the payment UUID and a hosted payment page URL (paymentURL).
2
Customer is redirected to the payment page The customer lands on https://yourdomain.com/p/{uuid} — a page hosted entirely on your own server. No third-party iframes, no external scripts that can steal card data.
3
A unique blockchain address is generated For Bitcoin, CryptoLink uses your xpub key and BIP32/BIP44/BIP84 HD derivation to generate a fresh, unique deposit address. For EVM chains and TRON, the customer pays to your merchant-owned smart contract collector address. The customer sees this address with a QR code. Your private key is never involved.
4
Customer sends cryptocurrency The customer sends the exact amount (or any amount for payment links) to the displayed address. The payment page auto-detects the currency (ETH, USDT, BTC, etc.) based on your configuration.
5
CryptoLink's address watcher detects the transaction CryptoLink's built-in watcher polls blockchain APIs directly every 15s — Blockstream/mempool.space for Bitcoin, go-ethereum RPC for EVM chains, and TronGrid for TRON. EVM detection is event-based: collector contracts emit a Received(address,uint256) log on every inbound payment, and the watcher indexes those logs via eth_getLogs. This catches not only direct customer sends but also payments routed through exchange withdrawal sweeps, batch payers (Disperse-style), payment splitters and multisigs — all of which deliver value via internal contract calls invisible to a top-level transaction scan. ERC-20 / TRC-20 detection uses the standard Transfer event; Bitcoin uses balance-change polling. Detection is deduplicated on (network, txHash, recipient) so a single batch transaction can correctly settle multiple invoices to different collector addresses.
6
Payment status updates in real-time The payment moves through states: pendinginProgress (mempool/broadcast) → success (confirmed) or failed/expired. The customer's payment page polls for updates and shows confirmation in real-time.
7
Merchant receives webhook notification Your webhook URL (set in Dashboard → Settings → Webhook URL) receives a signed POST with the payment status, amount, and your order ID. Verify the X-Signature header and fulfill the order.
8
Funds arrive directly in your wallet The cryptocurrency was sent directly to an address you control. It sits there until you withdraw it using your hardware wallet or cold storage. CryptoLink never held the funds — it only watched the blockchain.
┌──────────────────┐ ┌────────────────────────┐ │ Your Store / │ API │ │ │ E-commerce │─────────▶│ CryptoLink Server │ │ Backend │ │ (your own VPS) │ └──────────────────┘ │ │ ▲ │ • Derives addresses │ │ Webhook │ • Manages payments │ │ (HMAC signed) │ • Polls blockchains │ └────────────────────│ │ └────────────┬───────────┘ │ Direct API polling ▼ ┌────────────────────────┐ │ Blockchain APIs │ │ (no middleware) │ │ │ │ • Blockstream (BTC) │ │ • go-ethereum (EVM) │ │ • TronGrid (TRON) │ └────────────┬───────────┘ │ ▼ ┌────────────────────────┐ │ Blockchain Network │ │ (BTC/ETH/TRON/…) │ │ │ │ Funds go DIRECTLY to │ │ merchant's wallet │ └────────────────────────┘

Payment States

StatusDescriptionTrigger
pendingPayment created, awaiting transactionCreated via API
lockedCustomer picked a currency; awaiting on-chain transferCustomer action on payment page
inProgressTransaction detected on-chain, awaiting confirmationsAddress watcher (event-based for EVM, balance-based for BTC, log-based for tokens)
successTransaction confirmed; merchant webhook deliveredAddress watcher (confirmed)
failedTransaction failed or wrong amountAddress watcher / system check
expiredPayment window elapsed (default: 24h)Scheduled job

Non-Custodial Architecture

This is the most important section of the documentation. Understanding why CryptoLink cannot steal your funds — even if the server is fully compromised — is fundamental to evaluating it as a payment solution.

The Core Guarantee

For Bitcoin, CryptoLink derives deposit addresses using only your extended public key (xpub). For EVM chains and TRON, each merchant deploys their own smart contract collector — only the merchant's wallet can withdraw funds from it. In both cases, the private key never touches the server and CryptoLink cannot spend your funds.

What is an Extended Public Key?

An extended public key is a concept from the BIP32 Hierarchical Deterministic (HD) wallet standard. It encodes a public key and a chain code that together allow derivation of an unlimited number of child public keys — and therefore an unlimited number of unique deposit addresses.

Crucially, knowing an extended public key allows you to derive addresses but gives you zero ability to sign transactions or move funds. The mathematical relationship is one-way:

Master Seed (BIP39 mnemonic — NEVER leaves hardware wallet) │ ▼ Master Private Key + Master Chain Code │ ├──▶ Master Public Key + Chain Code = zpub/xpub │ │ │ ├──▶ Child pubkey at m/84'/0'/0'/0/0 → bc1q...abc (Address 0) │ ├──▶ Child pubkey at m/84'/0'/0'/0/1 → bc1q...def (Address 1) │ ├──▶ Child pubkey at m/84'/0'/0'/0/2 → bc1q...ghi (Address 2) │ └──▶ ... unlimited unique addresses │ └──▶ Master Private Key (stays in Ledger/Trezor — NEVER exported) CryptoLink only ever receives the extended public key. CryptoLink can generate addresses 0, 1, 2, ... ∞ CryptoLink CANNOT derive the private key from the public key (one-way function).

Key Format Auto-Detection (SLIP-0132)

Different wallet software exports keys with different prefixes. CryptoLink uses the SLIP-0132 standard to auto-detect the key format from the first 4 bytes (version prefix) and automatically selects the correct derivation path and address format:

PrefixStandardAddress FormatDerivation PathExample Address
zpub BIP84 Native SegWit (bech32) m/84'/0'/0' bc1q... Recommended
ypub BIP49 SegWit (P2SH-wrapped) m/49'/0'/0' 3...
xpub BIP44 Legacy (P2PKH) m/44'/0'/0' 1...

Internally, CryptoLink converts all key formats (zpub, ypub) to xpub version bytes for the derivation library, but preserves the correct derivation path based on the original format. This means the same key always produces the same addresses regardless of how it is encoded.

Which format should I use?

zpub (Native SegWit / BIP84) is recommended. It produces bc1q... addresses with the lowest transaction fees. This is the default in modern wallets like Electrum, Trezor Suite, and BlueWallet. If your wallet only exports xpub, CryptoLink will treat it as Legacy (1-prefix addresses).

Hardware Wallet Compatibility

Most modern wallets export the correct SLIP-0132 prefix. However, some hardware wallet software (notably Ledger Live) exports all keys as xpub regardless of whether the account uses Native SegWit (bc1q) or Legacy (1-prefix) addresses. This can cause an address format mismatch.

WalletExport FormatNotes
Exoduszpub / ypub / xpubSettings → Developer Menu → Export. All three formats available.
ElectrumzpubWallet → Information → Master Public Key. Correct format for SegWit.
Trezor SuitezpubAccount → Show Public Key. Correct format for SegWit accounts.
Ledger Livexpub onlyAlways exports xpub even for SegWit accounts. Use Electrum or Sparrow to export zpub instead.
ColdcardzpubSettings → Advanced → Export → XPUB. Uses correct SLIP-0132 prefixes.
Sparrow WalletzpubSettings → Show Master Public Key. Always correct format.
BlueWalletzpubWallet → Details → Show XPUB. Uses correct format.
Address Verification After Import

After importing your key, CryptoLink derives the first receive address and displays it for verification. Compare this address with the first receive address in your wallet software. If they match, your configuration is correct. If they don't match, you may need to re-export your key in the correct format (zpub for bc1q addresses).

How Address Derivation Works

When a customer initiates a payment, CryptoLink increments an internal counter and derives the next child address from your extended public key using the BIP32 derivation path (e.g., BIP84 path m/84'/0'/0'/0/n for Native SegWit):

Derivation Logic (conceptual)
// CryptoLink receives zpub from merchant during setup
// (xpub and ypub also accepted — auto-converted internally)
zpub := "zpub6rFR7y4Q2A...your_key..."

// For each new payment, derive the next index
paymentIndex := db.GetNextAddressIndex(merchantID)

// Derive child public key using BIP32 (path: m/84'/0'/0'/0/{index})
childKey := bip32.DeriveChildKey(zpub, paymentIndex)

// Convert to Bitcoin bech32 address
address := bitcoin.PublicKeyToBech32Address(childKey)
// Result: "bc1q3a2d8f..." — a fresh, unique Native SegWit address
// This address belongs to the merchant's HD wallet
// ONLY the merchant's private key (in their hardware wallet) can sign from it

Smart Contract Collectors (EVM & TRON)

For EVM-compatible chains (ETH, MATIC, BSC, Arbitrum, Avalanche) and TRON, CryptoLink uses a smart contract collector pattern instead of xpub derivation. Each merchant deploys their own collector contract via a clone factory (EIP-1167 minimal proxy) — a cheap (~$0.50) deployment that creates a unique contract owned exclusively by the merchant's wallet address.

The collector contract has only one authorized withdrawer: the merchant. The contract accepts incoming payments (native coins and tokens), and only the merchant's wallet can call withdrawAll() or withdrawToken() to move funds out.

Admin deploys factory once (~$16 total): MerchantCollectorV2 (implementation) + CryptoLinkCloneFactory Merchant deploys clone (~$0.50): factory.deploy(merchantWalletAddress) │ ▼ EIP-1167 Minimal Proxy → points to implementation │ ├── initialize(merchantWalletAddress) → sets owner ├── receive() → accepts payments ├── withdrawAll(tokenAddresses[]) → owner only └── withdrawToken(tokenAddress) → owner only CryptoLink server CANNOT call withdraw — only the merchant's wallet can.
TRON Smart Contracts

TRON uses the same clone factory pattern, deployed via TronLink browser extension. Contracts are compiled with --evm-version paris (no PUSH0) for TVM compatibility. Known TRC-20 tokens: USDT (TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t), USDC (TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8).

Trust Model

With CryptoLink, here is what you must trust — and what you don't:

EntityTrust Required?Reason
CryptoLink code✓ YesOpen source — you can read and audit every line
Your server✓ YesYou control it — standard server hardening applies
Blockchain APIs (Blockstream, TronGrid)⚡ PartialTrusted to report transactions accurately; cannot move your funds. Multiple fallback sources reduce single-point risk.
CryptoLink developers✗ NoNo access to your server, keys, or funds
CryptoLink company✗ NoSelf-hosted — no ongoing relationship with any company
Payment processor✗ NoThere is none — no third party in the payment path

Comparison: CryptoLink vs Centralized Alternatives

Feature CryptoLink Stripe Crypto Coinbase Commerce BitPay
Non-custodial ✓ Yes ✗ No ✗ No ⚡ Partial
Self-hosted ✓ Yes ✗ No ✗ No ✗ No
Open source ✓ MIT ✗ No ✗ No ✗ No
Transaction fees ✓ None 1.5% 1% 1%
Private key custody You hold keys They hold keys They hold keys They hold keys
Account freezing risk ✓ None ✗ High ✗ High ✗ High
KYC required ✓ No ✗ Yes ✗ Yes ✗ Yes
Data privacy 100% on your server Shared with Stripe Shared with Coinbase Shared with BitPay
Server shutdown risk ✓ Zero ✗ Yes ✗ Yes ✗ Yes

Open Source & Security

CryptoLink is released under the MIT License. Every line of backend Go code, every frontend React component, every database migration, and every configuration template is available for inspection, audit, and modification.

MIT License

The MIT license grants you unlimited rights to use, copy, modify, merge, publish, distribute, sublicense, and sell copies of the software. There are no usage restrictions, no royalties, and no CLAs. Fork it. White-label it. Build a business on it.

Security Design Principles

2025 Security Audit Summary

CryptoLink underwent an independent security audit in 2025. All critical and high-severity findings were resolved prior to the stable release. See the full Security Audit section for the complete report.

Audit Result: All Critical & High Findings Resolved

The 2025 audit identified 0 Critical, 2 High, 4 Medium, and 3 Low severity findings. All High and Medium findings were fixed before the stable release. The 3 Low findings are accepted risks with documented mitigations.

Reporting Vulnerabilities

If you discover a security vulnerability in CryptoLink, please report it responsibly via email at [email protected]. Do not disclose security vulnerabilities publicly before they are resolved.

Quick Start

Get your first test payment running in under 10 minutes. This guide assumes you have a running CryptoLink instance. If you need to set one up, see the Self-Hosting Guide.

Step 1: Create an Account

Navigate to https://yourdomain.com/merchants/login?mode=register and create a merchant account. The first account on a fresh installation automatically receives admin privileges.

Step 2: Configure Your Wallets

CryptoLink uses two different wallet approaches depending on the blockchain:

Bitcoin (xpub / zpub / ypub)

Go to Dashboard → Settings → Wallets and import your Bitcoin extended public key. CryptoLink accepts zpub (Native SegWit, recommended), ypub (SegWit), and xpub (Legacy) — the format and derivation path are auto-detected from the key prefix.

Address Verification

After importing your key, CryptoLink derives the first receive address and displays it for verification. Compare this address with the first receive address in your wallet software. If they match, your wallet is correctly configured. If they don't, you likely need to re-export your key in the correct format (zpub for bc1q addresses). See the Hardware Wallet Compatibility section for details.

EVM Chains & TRON (Smart Contract Collector)

For ETH, MATIC, BSC, Arbitrum, Avalanche, and TRON, each merchant deploys a smart contract collector via the dashboard. This is a one-time setup per chain:

The collector contract is owned by your wallet — only you can withdraw funds from it.

Never Share Your Seed Phrase

You only need the extended public key (xpub, ypub, or zpub), never your seed phrase or private key. These keys are safe to share with CryptoLink — they can only be used to derive addresses, not to move funds.

Step 3: Generate an API Token

Navigate to Dashboard → API Tokens → Create Token. Copy the token — it is shown only once.

Step 4: Create Your First Payment

bash
curl -X POST https://yourdomain.com/api/merchant/v1/merchant/{merchantId}/payment \
  -H "Content-Type: application/json" \
  -H "X-CRYPTOLINK-TOKEN: your_api_token_here" \
  -d '{
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "currency": "USD",
    "price": 50.00,
    "orderId": "order_001",
    "description": "Test payment",
    "redirectUrl": "https://yourstore.com/thank-you"
  }'

You'll receive a response like:

json
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "paymentURL": "https://yourdomain.com/p/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "status": "pending",
  "price": "50.00",
  "currency": "USD",
  "orderId": "order_001",
  "type": "payment"
}

Step 5: Redirect Your Customer

Redirect the customer to the paymentURL from the response. They'll see a payment page where they choose a cryptocurrency, get a unique deposit address and QR code, and see real-time status updates.

Step 6: Handle the Webhook

When payment completes, your webhook URL (configured in Settings → Webhook) receives a POST request. Verify the X-Signature header and fulfill the order:

php
<?php
// Verify webhook signature
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$body = file_get_contents('php://input');
$secret = getenv('CRYPTOLINK_HMAC_SECRET');

$expected = base64_encode(hash_hmac('sha512', $body, $secret, true));

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Invalid signature');
}

$payload = json_decode($body, true);

if ($payload['status'] === 'success') {
    // Fulfill the order using merchantOrderID (your orderId from payment creation)
    fulfillOrder($payload['merchantOrderID']);
}

http_response_code(200);
echo 'OK';

That's it. Your CryptoLink integration is live. For production use, review the full Webhooks and Integration sections below.

Authentication

CryptoLink uses token-based authentication. Every API request must include your merchant API token in the request header.

API Token Header

Include your token in every request using the X-CRYPTOLINK-TOKEN header:

bash
curl https://yourdomain.com/api/merchant/v1/merchant/{merchantId}/payment \
  -H "X-CRYPTOLINK-TOKEN: cl_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Creating API Tokens

  1. Log in to the merchant dashboard at /merchants/login
  2. Click your account name in the top-right corner
  3. Navigate to API Tokens
  4. Click Create Token and give it a descriptive name
  5. Copy the token immediately — it will not be shown again
Token Security

Store API tokens in environment variables or a secrets manager — never hardcode them in source code or commit them to version control. Tokens grant full merchant API access. Rotate tokens immediately if you suspect compromise.

Token Scopes

Currently, all API tokens grant full merchant-level access. Per-token scope restrictions are planned for a future release. Use separate merchant accounts if you need permission isolation between applications.

Authentication Errors

HTTP StatusErrorCause
401unauthorizedMissing or invalid token header
403forbiddenToken valid but lacks permission for this resource
429rate_limitedToo many authentication attempts

Base URL

All API endpoints include your Merchant UUID, which you can find in the merchant dashboard under Settings. Replace {merchantId} with your UUID:

text
https://yourdomain.com/api/merchant/v1/merchant/{merchantId}

Example for merchant UUID 550e8400-e29b-41d4-a716-446655440000:

text
https://yourdomain.com/api/merchant/v1/merchant/550e8400-e29b-41d4-a716-446655440000/payment

All requests must use HTTPS. HTTP requests are redirected to HTTPS and tokens transmitted over HTTP are considered compromised.

Payments API

The Payments API is the core of CryptoLink. Use it to create, retrieve, and list payment requests. Each payment generates a unique deposit address and hosted payment page.

Endpoints

POST /api/merchant/v1/merchant/{merchantId}/payment Create a new payment
GET /api/merchant/v1/merchant/{merchantId}/payment/{id} Retrieve a payment by ID
GET /api/merchant/v1/merchant/{merchantId}/payment List payments with pagination

Create Payment

POST /api/merchant/v1/merchant/{merchantId}/payment

How it works

You create a payment with a fiat amount (USD or EUR). CryptoLink returns a hosted payment page URL. Your customer visits that page and chooses which cryptocurrency to pay with. You receive a webhook when payment completes. Configure your webhook URL in the merchant dashboard under Settings → Webhook.

Request Body

FieldTypeRequiredDescription
idstring (UUID v4)RequiredUnique UUID you generate per payment — used for idempotency. Re-submitting with the same UUID returns the existing payment.
currencystringRequiredFiat currency: "USD" or "EUR". The customer chooses which crypto to pay with on the payment page.
pricenumberRequiredAmount in fiat currency (e.g. 100.00). Min: 0.01
orderIdstringOptionalYour internal order reference — returned in webhook payloads for matching
descriptionstringOptionalHuman-readable description (max 128 chars)
redirectUrlstringOptionalURL shown to customer after payment detected. Defaults to merchant website.
isTestbooleanOptionalSet true for testnet payments (default: false)
json — Request
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "currency": "USD",
  "price": 100.00,
  "orderId": "order_123",
  "description": "Payment for Order #123",
  "redirectUrl": "https://yourstore.com/thank-you"
}

Response

json — Response 201 Created
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "paymentURL": "https://yourdomain.com/p/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "status": "pending",
  "price": "100.00",
  "currency": "USD",
  "orderId": "order_123",
  "description": "Payment for Order #123",
  "type": "payment",
  "createdAt": "2025-06-01T14:30:00Z"
}

Get Payment

GET /api/merchant/v1/merchant/{merchantId}/payment/{id}

Retrieve the current state of a payment by its UUID. Poll this endpoint to check status if webhooks are unavailable, though webhooks are strongly preferred.

json — Response 200 OK
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "paymentURL": "https://yourdomain.com/p/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "status": "success",
  "price": "100.00",
  "currency": "USD",
  "orderId": "order_123",
  "description": "Payment for Order #123",
  "type": "payment",
  "createdAt": "2025-06-01T14:30:00Z"
}

List Payments

GET /api/merchant/v1/merchant/{merchantId}/payment?cursor=0&limit=20

Query Parameters

ParameterTypeDefaultDescription
skipinteger0Number of records to skip (for pagination)
limitinteger20Maximum records to return (max: 100)
statusstringallFilter by status: pending, inProgress, success, failed, expired
currencystringallFilter by currency ticker

Error Responses

StatusCodeDescription
400validation_errorInvalid request body or parameters
401unauthorizedMissing or invalid API token
402limit_exceededSubscription plan limit exceeded (payment count, volume, or merchant count). Response includes upgrade_url.
404not_foundPayment ID does not exist
429rate_limitedRate limit exceeded
500internal_errorServer error (check server logs)

Webhooks

Webhooks are the recommended way to receive payment status updates. CryptoLink sends a signed HTTP POST to your webhook URL (configured in Dashboard → Settings → Webhook URL) whenever a payment status changes. All webhooks are signed with HMAC-SHA512 for security verification.

Webhook Events

EventTriggerAction Recommended
payment.pendingPayment created (initial state)Display "awaiting payment" UI
payment.inProgressTransaction broadcast to mempoolShow "payment processing" status
payment.successTransaction confirmed on-chainFulfill the order
payment.failedTransaction failed or underpaidNotify customer, offer retry
payment.expiredPayment window elapsed (24h default)Cancel order, notify customer

Webhook Payload

json — Webhook POST body
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "status": "success",
  "merchantOrderID": "order_123",
  "amount": "100.00",
  "currency": "USD",
  "cryptoAmount": "107.24",
  "cryptoCurrency": "ETH_USDT",
  "selectedBlockchain": "ETH",
  "customerEmail": "[email protected]",
  "isTest": false,
  "linkId": null
}
FieldDescription
idThe payment UUID — same as the id you provided when creating the payment
statusCurrent payment status: pending, inProgress, success, failed
merchantOrderIDYour orderId from payment creation — use this to match to your order
amountInvoice amount in fiat (e.g. "100.00")
currencyFiat currency: "USD" or "EUR"
cryptoAmountCrypto amount the customer paid/is expected to pay
cryptoCurrencyCrypto ticker used (e.g. "ETH_USDT", "BTC", "USDT_TRON")
selectedBlockchainBlockchain network (e.g. "ETH", "BTC", "TRON")
customerEmailCustomer email if provided
isTestTrue if this is a testnet payment
linkIdPayment link UUID if payment originated from a link, otherwise null

Signature Verification

Every webhook request includes an X-Signature header containing a base64-encoded HMAC-SHA512 signature of the raw request body. Always verify this signature before processing any webhook.

The HMAC secret is set in your merchant dashboard under Settings → Webhook → HMAC Secret. You choose this secret yourself — set it to a long random string.

Always Verify Signatures

Never fulfill orders based on unverified webhooks. An attacker who knows your callback URL can forge payment success notifications without signature verification. Use constant-time comparison (hash_equals in PHP, hmac.Equal in Go) to prevent timing attacks.

php
<?php
function verifyWebhookSignature(string $body, string $signature, string $secret): bool {
    $expected = base64_encode(hash_hmac('sha512', $body, $secret, true));
    return hash_equals($expected, $signature);
}

$body      = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$secret    = getenv('CRYPTOLINK_HMAC_SECRET');

if (!verifyWebhookSignature($body, $signature, $secret)) {
    http_response_code(401);
    exit('Signature verification failed');
}

$event = json_decode($body, true);

switch ($event['status']) {
    case 'success':
        fulfillOrder($event['merchantOrderID'], $event['amount'], $event['currency']);
        break;
    case 'failed':
    case 'expired':
        cancelOrder($event['merchantOrderID']);
        notifyCustomer($event['customerEmail'], $event['status']);
        break;
}

http_response_code(200);
echo json_encode(['received' => true]);
javascript
const crypto = require('crypto');
const express = require('express');
const app = express();

// Use raw body parser for webhook endpoint
app.use('/webhooks/cryptolink', express.raw({ type: 'application/json' }));

function verifySignature(body, signature, secret) {
    const expected = crypto
        .createHmac('sha512', secret)
        .update(body)
        .digest('base64');
    // Constant-time comparison
    return crypto.timingSafeEqual(
        Buffer.from(expected),
        Buffer.from(signature)
    );
}

app.post('/webhooks/cryptolink', (req, res) => {
    const signature = req.headers['x-signature'];
    const secret    = process.env.CRYPTOLINK_HMAC_SECRET;
    const rawBody   = req.body; // Buffer (raw body)

    if (!verifySignature(rawBody, signature, secret)) {
        return res.status(401).json({ error: 'Invalid signature' });
    }

    const event = JSON.parse(rawBody.toString());

    if (event.status === 'success') {
        fulfillOrder(event.merchantOrderID, event.amount, event.currency);
    } else if (event.status === 'failed' || event.status === 'expired') {
        cancelOrder(event.merchantOrderID);
    }

    res.status(200).json({ received: true });
});
python
import hmac
import hashlib
import base64
import os
from flask import Flask, request, jsonify, abort

app = Flask(__name__)

def verify_signature(body: bytes, signature: str, secret: str) -> bool:
    mac = hmac.new(secret.encode(), body, hashlib.sha512)
    expected = base64.b64encode(mac.digest()).decode()
    return hmac.compare_digest(expected, signature)

@app.route('/webhooks/cryptolink', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Signature', '')
    secret    = os.environ['CRYPTOLINK_HMAC_SECRET']
    raw_body  = request.get_data()  # raw bytes before JSON parse

    if not verify_signature(raw_body, signature, secret):
        abort(401, description='Invalid webhook signature')

    event = request.get_json()

    if event['status'] == 'success':
        fulfill_order(event['merchantOrderID'], event['amount'], event['currency'])
    elif event['status'] in ('failed', 'expired'):
        cancel_order(event['merchantOrderID'])

    return jsonify({'received': True}), 200
go
package main

import (
    "crypto/hmac"
    "crypto/sha512"
    "encoding/base64"
    "encoding/json"
    "io"
    "net/http"
    "os"
)

type WebhookEvent struct {
    ID              string `json:"id"`
    Status          string `json:"status"`
    MerchantOrderID string `json:"merchantOrderID"`
    Amount          string `json:"amount"`
    Currency        string `json:"currency"`
    CryptoAmount    string `json:"cryptoAmount"`
    CryptoCurrency  string `json:"cryptoCurrency"`
    CustomerEmail   string `json:"customerEmail"`
    IsTest          bool   `json:"isTest"`
}

func verifySignature(body []byte, signature, secret string) bool {
    mac := hmac.New(sha512.New, []byte(secret))
    mac.Write(body)
    expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(signature))
}

func handleWebhook(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    signature := r.Header.Get("X-Signature")
    secret := os.Getenv("CRYPTOLINK_HMAC_SECRET")

    if !verifySignature(body, signature, secret) {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }

    var event WebhookEvent
    json.Unmarshal(body, &event)

    switch event.Status {
    case "success":
        fulfillOrder(event.MerchantOrderID, event.Amount, event.Currency)
    case "failed", "expired":
        cancelOrder(event.MerchantOrderID)
    }

    w.WriteHeader(http.StatusOK)
    w.Write([]byte(`{"received":true}`))
}

Retry Policy

If your callback URL returns a non-2xx HTTP status, CryptoLink will retry delivery using exponential backoff: after 1 minute, 5 minutes, 30 minutes, and 2 hours. After 4 failed attempts, the webhook is marked as failed. You can view and manually retry failed webhooks from the dashboard.

Your endpoint should respond within 30 seconds. If processing takes longer, respond with 200 OK immediately and process the event asynchronously via a queue.

Idempotency

Your webhook handler must be idempotent. The same event may be delivered more than once due to network issues or retries. Use the payment id field to deduplicate events and avoid double-fulfilling orders.

Wallet Configuration API

Manage Bitcoin HD wallet (xpub) configurations programmatically. These endpoints allow you to import extended public keys, derive addresses, and manage wallet lifecycle. For EVM and TRON wallets, use the dashboard's smart contract deployment flow instead.

Endpoints

POST /api/merchant/v1/merchant/{merchantId}/xpub-wallet Import an extended public key
GET /api/merchant/v1/merchant/{merchantId}/xpub-wallet List all xpub wallets
GET /api/merchant/v1/merchant/{merchantId}/xpub-wallet/{walletId} Get wallet details
DELETE /api/merchant/v1/merchant/{merchantId}/xpub-wallet/{walletId} Delete a wallet configuration
POST /api/merchant/v1/merchant/{merchantId}/xpub-wallet/{walletId}/derive Derive the next address
GET /api/merchant/v1/merchant/{merchantId}/xpub-wallet/{walletId}/next-address Get next unused address
GET /api/merchant/v1/merchant/{merchantId}/xpub-wallet/{walletId}/addresses List all derived addresses

Import Extended Public Key

POST /api/merchant/v1/merchant/{merchantId}/xpub-wallet

Import a Bitcoin extended public key. Accepts xpub, ypub, or zpub formats. The derivation path is auto-detected from the key's version bytes (SLIP-0132) — you do not need to specify it manually.

Request Body

FieldTypeRequiredDescription
blockchainstringRequiredBlockchain identifier: "BTC"
xpubstringRequiredExtended public key in any format: xpub..., ypub..., or zpub...
derivationPathstringOptionalOverride derivation path (e.g. m/84'/0'/0'). Auto-detected from key format if omitted.
bash — Import a zpub key
curl -X POST https://yourdomain.com/api/merchant/v1/merchant/{merchantId}/xpub-wallet \
  -H "Content-Type: application/json" \
  -H "X-CRYPTOLINK-TOKEN: your_api_token" \
  -d '{
    "blockchain": "BTC",
    "xpub": "zpub6rFR7y4Q2AijBEqTUqiGMcA8k3KNBNzihkghGjRjkTLmBgR..."
  }'
json — Response 201 Created
{
  "uuid": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "blockchain": "BTC",
  "derivationPath": "m/84'/0'/0'",
  "lastDerivedIndex": -1,
  "createdAt": "2026-03-14T12:00:00Z"
}
Auto-Detection

The derivationPath in the response shows the auto-detected path: m/84'/0'/0' for zpub (Native SegWit), m/49'/0'/0' for ypub (SegWit), m/44'/0'/0' for xpub (Legacy). The lastDerivedIndex starts at -1 — the first derived address will be at index 0.

List Wallets

GET /api/merchant/v1/merchant/{merchantId}/xpub-wallet

Returns all active xpub wallets for the merchant.

json — Response 200 OK
[
  {
    "uuid": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "blockchain": "BTC",
    "derivationPath": "m/84'/0'/0'",
    "lastDerivedIndex": 5,
    "createdAt": "2026-03-14T12:00:00Z"
  }
]

Delete Wallet

DELETE /api/merchant/v1/merchant/{merchantId}/xpub-wallet/{walletId}

Permanently removes the wallet configuration and all derived addresses from CryptoLink. Your funds remain safe — this only removes the public key from the server. Returns 204 No Content on success.

Derive Address

POST /api/merchant/v1/merchant/{merchantId}/xpub-wallet/{walletId}/derive

Derives the next sequential address from the wallet's extended public key. No request body needed. Each call increments the internal counter and returns a new unique address.

json — Response 201 Created
{
  "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
  "derivationPath": "m/84'/0'/0'/0/0",
  "derivationIndex": 0,
  "isUsed": false,
  "createdAt": "2026-03-14T12:01:00Z"
}

Get Next Unused Address

GET /api/merchant/v1/merchant/{merchantId}/xpub-wallet/{walletId}/next-address

Returns the next unused (unreserved) derived address. If all existing addresses are in use, derives a new one automatically.

List Derived Addresses

GET /api/merchant/v1/merchant/{merchantId}/xpub-wallet/{walletId}/addresses

Returns all addresses that have been derived from this wallet, including their usage status.

json — Response 200 OK
[
  {
    "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
    "derivationPath": "m/84'/0'/0'/0/0",
    "derivationIndex": 0,
    "isUsed": true,
    "createdAt": "2026-03-14T12:01:00Z"
  },
  {
    "uuid": "b2c3d4e5-f678-9012-bcde-f12345678901",
    "address": "bc1q9h5yjda0hs9ftlg3f0lm45rg9kpa9wfvyaalqq",
    "derivationPath": "m/84'/0'/0'/0/1",
    "derivationIndex": 1,
    "isUsed": false,
    "createdAt": "2026-03-14T12:02:00Z"
  }
]

Error Responses

StatusCodeDescription
400validation_errorInvalid key format, missing fields, or wallet already exists for this blockchain
401unauthorizedMissing or invalid API token
404not_foundWallet ID does not exist
One Wallet Per Blockchain

Each merchant can have at most one active xpub wallet per blockchain (currently BTC only). To change the key, delete the existing wallet first, then import the new key. Deleting a wallet removes the public key and all derived addresses from CryptoLink but does not affect your funds — they remain in your wallet.

Currencies & Networks

CryptoLink supports 17 currency configurations across 7 blockchain networks. Bitcoin uses xpub-based HD address derivation. EVM chains and TRON use merchant-owned smart contract collectors deployed via the dashboard.

Bitcoin (xpub-based)

CurrencyTickerNetworkWallet TypeDetectionNotes
Bitcoin BTC Bitcoin Mainnet xpub / zpub / ypub (auto-detected) Blockstream + mempool.space Accepts all key formats. zpub = bech32 (bc1q, recommended), ypub = SegWit (3-prefix), xpub = Legacy (1-prefix)

EVM Chains (Smart Contract Collector)

CurrencyTickerNetworkStandardDetectionNotes
EthereumETHEthereumNativego-ethereum RPCSmart contract collector
Tether USD (ERC-20)USDT_ETHEthereumERC-20go-ethereum RPCSame collector as ETH
USD Coin (ERC-20)USDC_ETHEthereumERC-20go-ethereum RPCSame collector as ETH
PolygonMATICPolygon PoSNativego-ethereum RPCLow-fee EVM chain
USDT (Polygon)USDT_MATICPolygon PoSERC-20go-ethereum RPCSame collector as MATIC
USDC (Polygon)USDC_MATICPolygon PoSERC-20go-ethereum RPCSame collector as MATIC
BNBBNBBNB Smart ChainNativego-ethereum RPCBinance ecosystem
USDT (BSC)USDT_BSCBNB Smart ChainBEP-20go-ethereum RPCSame collector as BNB
Arbitrum ETHARBArbitrum OneNativego-ethereum RPCL2 rollup, low fees
USDT (Arbitrum)USDT_ARBArbitrum OneERC-20go-ethereum RPCSame collector as ARB
USDC (Arbitrum)USDC_ARBArbitrum OneERC-20go-ethereum RPCSame collector as ARB
AvalancheAVAXAvalanche C-ChainNativego-ethereum RPCFast finality
USDT (Avalanche)USDT_AVAXAvalanche C-ChainERC-20go-ethereum RPCSame collector as AVAX
USDC (Avalanche)USDC_AVAXAvalanche C-ChainERC-20go-ethereum RPCSame collector as AVAX

TRON (Smart Contract Collector)

CurrencyTickerNetworkStandardDetectionNotes
TRONTRXTRON NetworkNativeTronGrid APIClone factory collector via TronLink
Tether USD (TRC-20)USDT_TRONTRON NetworkTRC-20TronGrid APIMost popular stablecoin; very low fees

Choosing a Currency

For stablecoin payments (avoiding volatility), USDT_TRON is the most popular choice due to extremely low network fees (~$0.01) and fast confirmation times (~3 seconds). For EVM ecosystems, USDT_MATIC and USDT_ARB offer similar low fees. USDC_ETH is preferred for merchants who want an Ethereum-based stablecoin despite higher gas.

For Bitcoin, expect higher network fees and slower confirmations (10-30 min per block). CryptoLink uses Blockstream and mempool.space APIs to detect both mempool and confirmed transactions.

For low-fee chains, Polygon (MATIC), BSC, Arbitrum, Avalanche, and TRON all offer sub-dollar transaction fees and fast finality. These are ideal for small-value payments where Ethereum gas would be prohibitive.

Network Fees

Network transaction fees are paid by the customer when sending. CryptoLink does not add any markup to network fees. The customer must send the exact amount displayed — underpayments are handled according to your configuration (rejected by default, or accepted with a tolerance).

Multi-Currency Setup

You can configure multiple currencies simultaneously. On the payment page, customers can switch between your enabled currencies before sending. Bitcoin uses an extended public key (xpub/zpub/ypub) with HD address derivation — each payment gets a unique address. EVM chains and TRON use a single smart contract collector per chain — all tokens on that chain share the same collector address.

Live Exchange Rate Conversion

CryptoLink uses real-time exchange rates from Binance (primary) with CoinGecko as fallback to convert your invoice amount to the exact crypto amount the customer must pay. Rates are cached for 30 seconds and cross-validated — if the two sources diverge by more than 5%, the rate is rejected for safety. Stablecoins (USDT, USDC) are priced at $1.00 with no API call.

Conversion flow (EUR invoice example)

StepOperationExample
1Merchant creates EUR invoice€85.00 EUR
2EUR → USD (fiat-to-fiat conversion)$92.35 USD
3USD → crypto (via Binance/CoinGecko rates)92.35 USDT or 0.00138 ETH
4Crypto amount shown on payment pageCustomer sends exact crypto amount
Rates are locked at payment creation

The crypto amount is calculated when the customer selects their payment method on the payment page — not when you create the payment. This gives customers the most current rate. The amount is then locked for the session duration.

Merchant Markup / Volatility Buffer

Crypto prices fluctuate between the time a customer sees the amount and when the transaction confirms on-chain. To protect against this volatility, merchants can configure a markup percentage per cryptocurrency in the Currencies & Fees page of the merchant dashboard.

How it works

Disclose fees to your customers

The markup is not shown on CryptoLink's payment page — your customers see only the invoice amount in fiat. You are responsible for informing customers about any markup on your own website or terms of service. A typical disclosure: "Crypto payments include a 1–2% market conversion fee to cover network and volatility costs."

Adding Currency Support

CryptoLink is extensible. New currencies can be added by implementing the currency provider interface in the Go backend and adding entries to currencies.json. For EVM-compatible chains, the existing smart contract collector and go-ethereum RPC infrastructure can be reused with minimal configuration. Community contributions for additional currency support are welcome.

Laravel / PHP Integration

A complete integration example for Laravel. This covers a service class, checkout controller, webhook handling, and route configuration. The pattern adapts to any PHP framework.

1. Environment Configuration

Add to your .env file:

bash — .env
CRYPTOLINK_BASE_URL=https://yourdomain.com
CRYPTOLINK_MERCHANT_ID=your-merchant-uuid-from-dashboard
CRYPTOLINK_API_TOKEN=cl_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
CRYPTOLINK_HMAC_SECRET=your_hmac_secret_from_dashboard_settings

2. CryptoLink Service Class

php — app/Services/CryptoLinkService.php
<?php

namespace App\Services;

use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;

class CryptoLinkService
{
    private string $baseUrl;
    private string $merchantId;
    private string $apiToken;
    private string $hmacSecret;

    public function __construct()
    {
        $this->baseUrl    = config('services.cryptolink.base_url');
        $this->merchantId = config('services.cryptolink.merchant_id');
        $this->apiToken   = config('services.cryptolink.api_token');
        $this->hmacSecret = config('services.cryptolink.hmac_secret');
    }

    /**
     * Create a new crypto payment.
     *
     * @param array $params Keys: id (UUID), currency (USD/EUR), price, orderId, description, redirectUrl
     */
    public function createPayment(array $params): array
    {
        $url = "{$this->baseUrl}/api/merchant/v1/merchant/{$this->merchantId}/payment";

        $response = Http::withHeaders([
            'X-CRYPTOLINK-TOKEN' => $this->apiToken,
            'Content-Type'       => 'application/json',
        ])->post($url, [
            'id'          => $params['id'],           // UUID v4 you generate
            'currency'    => $params['currency'],      // "USD" or "EUR"
            'price'       => $params['price'],         // float e.g. 99.99
            'orderId'     => $params['orderId'] ?? null,
            'description' => $params['description'] ?? null,
            'redirectUrl' => $params['redirectUrl'] ?? null,
            'isTest'      => $params['isTest'] ?? false,
        ]);

        if ($response->failed()) {
            throw new \RuntimeException(
                'CryptoLink API error: ' . $response->body(),
                $response->status()
            );
        }

        return $response->json();
    }

    /**
     * Retrieve an existing payment by its UUID.
     */
    public function getPayment(string $paymentId): array
    {
        $url = "{$this->baseUrl}/api/merchant/v1/merchant/{$this->merchantId}/payment/{$paymentId}";

        $response = Http::withHeaders([
            'X-CRYPTOLINK-TOKEN' => $this->apiToken,
        ])->get($url);

        if ($response->failed()) {
            throw new \RuntimeException('Payment not found', 404);
        }

        return $response->json();
    }

    /**
     * Verify a webhook signature (X-Signature header).
     * Returns true if the signature is valid.
     */
    public function verifyWebhookSignature(string $rawBody, string $signature): bool
    {
        $expected = base64_encode(
            hash_hmac('sha512', $rawBody, $this->hmacSecret, true)
        );
        return hash_equals($expected, $signature);
    }
}

3. Service Provider / Config Binding

php — config/services.php (add this entry)
'cryptolink' => [
    'base_url'    => env('CRYPTOLINK_BASE_URL'),
    'merchant_id' => env('CRYPTOLINK_MERCHANT_ID'),
    'api_token'   => env('CRYPTOLINK_API_TOKEN'),
    'hmac_secret' => env('CRYPTOLINK_HMAC_SECRET'),
],

4. Checkout Controller

php — app/Http/Controllers/CheckoutController.php
<?php

namespace App\Http\Controllers;

use App\Models\Order;
use App\Services\CryptoLinkService;
use Illuminate\Http\Request;

class CheckoutController extends Controller
{
    public function __construct(private CryptoLinkService $cryptoLink) {}

    /**
     * Initiate crypto checkout for an order.
     */
    public function initiateCryptoPayment(Request $request, Order $order)
    {
        try {
            $payment = $this->cryptoLink->createPayment([
                'id'          => \Ramsey\Uuid\Uuid::uuid4()->toString(), // unique per payment
                'currency'    => 'USD',       // or 'EUR' — fiat currency of invoice
                'price'       => $order->total_usd,
                'orderId'     => (string) $order->id,
                'description' => "Order #{$order->id} — {$order->item_summary}",
                'redirectUrl' => route('orders.thankyou', $order),
            ]);

            // Store the payment ID on the order for lookup later
            $order->update([
                'crypto_payment_id'  => $payment['id'],
                'crypto_payment_url' => $payment['paymentURL'],
                'status'             => 'awaiting_payment',
            ]);

            // Redirect customer to the CryptoLink-hosted payment page
            return redirect($payment['paymentURL']);

        } catch (\RuntimeException $e) {
            return back()->withErrors(['payment' => 'Could not initiate payment: ' . $e->getMessage()]);
        }
    }
}

5. Webhook Controller

php — app/Http/Controllers/WebhookController.php
<?php

namespace App\Http\Controllers;

use App\Models\Order;
use App\Services\CryptoLinkService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class WebhookController extends Controller
{
    public function __construct(private CryptoLinkService $cryptoLink) {}

    public function handleCryptoLink(Request $request)
    {
        $rawBody   = $request->getContent();
        $signature = $request->header('X-Signature', '');

        // 1. Verify signature
        if (!$this->cryptoLink->verifyWebhookSignature($rawBody, $signature)) {
            Log::warning('CryptoLink: invalid webhook signature');
            return response()->json(['error' => 'Invalid signature'], 401);
        }

        $event   = $request->json()->all();
        $orderId = $event['merchantOrderID'] ?? null; // your orderId from payment creation

        if (!$orderId) {
            return response()->json(['received' => true]);
        }

        $order = Order::find($orderId);
        if (!$order) {
            Log::error("CryptoLink webhook: order {$orderId} not found");
            return response()->json(['error' => 'Order not found'], 404);
        }

        // 2. Handle event (idempotent — check current status first)
        switch ($event['status']) {
            case 'success':
                if ($order->status !== 'completed') {
                    $order->update(['status' => 'completed']);
                    $order->fulfill(); // your fulfillment logic
                    Log::info("CryptoLink: order {$orderId} fulfilled", $event);
                }
                break;

            case 'inProgress':
                $order->update(['status' => 'payment_processing']);
                break;

            case 'failed':
                $order->update(['status' => 'payment_failed']);
                break;
        }

        return response()->json(['received' => true]);
    }
}

6. Routes

php — routes/web.php
<?php
use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\WebhookController;

// Checkout — requires auth
Route::middleware('auth')->group(function () {
    Route::post('/orders/{order}/pay-crypto', [CheckoutController::class, 'initiateCryptoPayment'])
        ->name('checkout.crypto');
});

// Webhook — must be CSRF-exempt
Route::post('/webhooks/cryptolink', [WebhookController::class, 'handleCryptoLink'])
    ->name('webhooks.cryptolink')
    ->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
CSRF Exemption Required

The webhook route must be excluded from Laravel's CSRF middleware, as CryptoLink's POST request does not include a CSRF token. Security is provided by the HMAC signature verification instead. Add the webhook path to $except in VerifyCsrfToken.php or use withoutMiddleware() as shown above.

Node.js Integration

A complete Express.js integration with Axios for API calls and built-in webhook signature verification.

Installation

bash
npm install axios express dotenv

CryptoLink Client

javascript — cryptolink.js
const axios  = require('axios');
const crypto = require('crypto');

class CryptoLinkClient {
    constructor({ baseUrl, merchantId, apiToken, hmacSecret }) {
        this.hmacSecret = hmacSecret;
        this.http = axios.create({
            baseURL: `${baseUrl}/api/merchant/v1/merchant/${merchantId}`,
            headers: {
                'X-CRYPTOLINK-TOKEN': apiToken,
                'Content-Type': 'application/json',
            },
            timeout: 15000,
        });
    }

    async createPayment(params) {
        const { data } = await this.http.post('/payment', {
            id:          params.id,          // UUID v4 you generate
            currency:    params.currency,    // 'USD' or 'EUR'
            price:       params.price,       // number e.g. 99.99
            orderId:     params.orderId,
            description: params.description,
            redirectUrl: params.redirectUrl,
            isTest:      params.isTest || false,
        });
        return data;
    }

    async getPayment(paymentId) {
        const { data } = await this.http.get(`/payment/${paymentId}`);
        return data;
    }

    verifyWebhookSignature(rawBody, signature) {
        const expected = crypto
            .createHmac('sha512', this.hmacSecret)
            .update(rawBody)
            .digest('base64');
        try {
            return crypto.timingSafeEqual(
                Buffer.from(expected, 'utf8'),
                Buffer.from(signature, 'utf8')
            );
        } catch {
            return false;
        }
    }
}

module.exports = CryptoLinkClient;

Express Routes

javascript — routes/payment.js
require('dotenv').config();
const express = require('express');
const router  = express.Router();
const CryptoLinkClient = require('../cryptolink');

const cl = new CryptoLinkClient({
    baseUrl:    process.env.CRYPTOLINK_BASE_URL,
    merchantId: process.env.CRYPTOLINK_MERCHANT_ID,
    apiToken:   process.env.CRYPTOLINK_API_TOKEN,
    hmacSecret: process.env.CRYPTOLINK_HMAC_SECRET,
});

// POST /checkout/crypto — initiate payment
router.post('/checkout/crypto', async (req, res) => {
    const { orderId } = req.body;
    const order = await Order.findById(orderId);
    if (!order) return res.status(404).json({ error: 'Order not found' });

    const payment = await cl.createPayment({
        id:          require('crypto').randomUUID(), // unique per payment
        currency:    'USD',   // or 'EUR'
        price:       order.totalUsd,
        orderId:     order.id.toString(),
        description: `Order #${order.id}`,
        redirectUrl: `${process.env.APP_URL}/orders/${order.id}/thank-you`,
    });

    await Order.findByIdAndUpdate(orderId, {
        cryptoPaymentId: payment.id,
        status: 'awaiting_payment',
    });

    res.redirect(payment.paymentURL);
});

// POST /webhooks/cryptolink — must use express.raw()
router.post('/webhooks/cryptolink',
    express.raw({ type: 'application/json' }),
    async (req, res) => {
        const signature = req.headers['x-signature'] || '';
        if (!cl.verifyWebhookSignature(req.body, signature)) {
            return res.status(401).json({ error: 'Invalid signature' });
        }
        const event = JSON.parse(req.body.toString());
        if (event.status === 'success') {
            await fulfillOrder(event.merchantOrderID); // your orderId
        }
        res.status(200).json({ received: true });
    }
);

module.exports = router;

Environment Variables

bash — .env
APP_URL=https://yourstore.com
CRYPTOLINK_BASE_URL=https://yourdomain.com
CRYPTOLINK_MERCHANT_ID=your-merchant-uuid-from-dashboard
CRYPTOLINK_API_TOKEN=cl_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
CRYPTOLINK_HMAC_SECRET=your_hmac_secret_from_dashboard_settings

Python Integration

Complete Python integration with Flask. The same pattern works with FastAPI, Django, or any Python web framework.

Installation

bash
pip install requests flask python-dotenv

CryptoLink Client

python — cryptolink.py
import hmac
import hashlib
import base64
import requests
from typing import Optional


class CryptoLinkClient:
    def __init__(self, base_url: str, merchant_id: str, api_token: str, hmac_secret: str):
        self.hmac_secret = hmac_secret
        self._base = f"{base_url.rstrip('/')}/api/merchant/v1/merchant/{merchant_id}"
        self.session = requests.Session()
        self.session.headers.update({
            "X-CRYPTOLINK-TOKEN": api_token,
            "Content-Type": "application/json",
        })

    def create_payment(self, payment_id: str, currency: str, price: float,
                        order_id: str = None, description: str = None,
                        redirect_url: str = None, is_test: bool = False) -> dict:
        """Create a payment. currency must be 'USD' or 'EUR'."""
        payload = {
            "id":       payment_id,  # UUID v4 you generate for idempotency
            "currency": currency,    # 'USD' or 'EUR'
            "price":    price,       # float e.g. 99.99
            "isTest":   is_test,
        }
        if order_id:    payload["orderId"]     = order_id
        if description: payload["description"] = description
        if redirect_url: payload["redirectUrl"] = redirect_url
        resp = self.session.post(f"{self._base}/payment", json=payload)
        resp.raise_for_status()
        return resp.json()

    def get_payment(self, payment_id: str) -> dict:
        resp = self.session.get(f"{self._base}/payment/{payment_id}")
        resp.raise_for_status()
        return resp.json()

    def verify_webhook(self, raw_body: bytes, signature: str) -> bool:
        mac = hmac.new(self.hmac_secret.encode("utf-8"), raw_body, hashlib.sha512)
        expected = base64.b64encode(mac.digest()).decode("utf-8")
        return hmac.compare_digest(expected, signature)

Flask Application

python — app.py
import os
import logging
from flask import Flask, request, jsonify, redirect, abort
from cryptolink import CryptoLinkClient

app = Flask(__name__)
log = logging.getLogger(__name__)

import uuid as uuid_lib

cl = CryptoLinkClient(
    base_url    = os.environ["CRYPTOLINK_BASE_URL"],
    merchant_id = os.environ["CRYPTOLINK_MERCHANT_ID"],
    api_token   = os.environ["CRYPTOLINK_API_TOKEN"],
    hmac_secret = os.environ["CRYPTOLINK_HMAC_SECRET"],
)

APP_URL = os.environ["APP_URL"]


@app.route("/checkout/crypto", methods=["POST"])
def initiate_crypto_payment():
    data     = request.get_json()
    order_id = data.get("order_id")
    order    = get_order(order_id)
    if not order:
        abort(404)

    payment = cl.create_payment(
        payment_id   = str(uuid_lib.uuid4()),  # unique per payment
        currency     = "USD",  # or "EUR"
        price        = order["total_usd"],
        order_id     = str(order["id"]),
        description  = f'Order #{order["id"]}',
        redirect_url = f'{APP_URL}/orders/{order["id"]}/thank-you',
    )
    update_order(order_id, {"crypto_payment_id": payment["id"], "status": "awaiting_payment"})
    return jsonify({"redirect_url": payment["paymentURL"]})


@app.route("/webhooks/cryptolink", methods=["POST"])
def handle_webhook():
    raw_body  = request.get_data()  # raw bytes — before JSON parsing
    signature = request.headers.get("X-Signature", "")

    if not cl.verify_webhook(raw_body, signature):
        log.warning("CryptoLink: invalid webhook signature")
        abort(401)

    event    = request.get_json(force=True)
    order_id = event.get("merchantOrderID")  # your orderId from payment creation
    status   = event.get("status")

    if order_id:
        order = get_order(order_id)
        if order:
            if status == "success" and order["status"] != "completed":
                fulfill_order(order_id)
                update_order(order_id, {"status": "completed"})
            elif status == "inProgress":
                update_order(order_id, {"status": "payment_processing"})
            elif status == "failed":
                update_order(order_id, {"status": "payment_failed"})

    return jsonify({"received": True}), 200


def get_order(oid): ...
def update_order(oid, data): ...
def fulfill_order(oid): ...

FastAPI Variant

python — fastapi_app.py
from fastapi import FastAPI, Request, HTTPException, Header
from typing import Optional
import os, json
from cryptolink import CryptoLinkClient

app = FastAPI()
cl  = CryptoLinkClient(
    base_url    = os.environ["CRYPTOLINK_BASE_URL"],
    merchant_id = os.environ["CRYPTOLINK_MERCHANT_ID"],
    api_token   = os.environ["CRYPTOLINK_API_TOKEN"],
    hmac_secret = os.environ["CRYPTOLINK_HMAC_SECRET"],
)

@app.post("/webhooks/cryptolink")
async def handle_webhook(
    request: Request,
    x_signature: Optional[str] = Header(None)
):
    raw_body = await request.body()
    if not x_signature or not cl.verify_webhook(raw_body, x_signature):
        raise HTTPException(status_code=401, detail="Invalid signature")
    event = json.loads(raw_body)
    if event.get("status") == "success":
        await fulfill_order_async(event.get("merchantOrderID"))
    return {"received": True}

WordPress / WooCommerce Integration

Integrate CryptoLink with WooCommerce by creating a custom payment gateway plugin. WooCommerce provides the WC_Payment_Gateway base class that handles most of the plumbing.

Plugin File Structure

bash
wp-content/plugins/cryptolink-woocommerce/
├── cryptolink-woocommerce.php   # Plugin bootstrap
└── includes/
    ├── class-cryptolink-gateway.php
    └── class-cryptolink-webhook.php

Plugin Bootstrap

php — cryptolink-woocommerce.php
<?php
/**
 * Plugin Name: CryptoLink Payment Gateway
 * Description: Accept cryptocurrency via your self-hosted CryptoLink gateway.
 * Version:     1.0.0
 */
if (!defined('ABSPATH')) exit;

add_action('plugins_loaded', function() {
    if (!class_exists('WC_Payment_Gateway')) return;
    require_once __DIR__ . '/includes/class-cryptolink-gateway.php';
    require_once __DIR__ . '/includes/class-cryptolink-webhook.php';
    add_filter('woocommerce_payment_gateways', function($g) {
        $g[] = 'WC_CryptoLink_Gateway'; return $g;
    });
    add_action('woocommerce_api_cryptolink_webhook', ['WC_CryptoLink_Webhook', 'handle']);
});

Gateway Class

php — class-cryptolink-gateway.php
<?php
class WC_CryptoLink_Gateway extends WC_Payment_Gateway {

    public function __construct() {
        $this->id                 = 'cryptolink';
        $this->method_title       = 'CryptoLink';
        $this->method_description = 'Accept crypto payments via your self-hosted CryptoLink gateway.';
        $this->supports           = ['products'];
        $this->init_form_fields();
        $this->init_settings();
        $this->title       = $this->get_option('title');
        $this->description = $this->get_option('description');
        add_action('woocommerce_update_options_payment_gateways_' . $this->id,
            [$this, 'process_admin_options']);
    }

    public function init_form_fields() {
        $this->form_fields = [
            'enabled'     => ['title' => 'Enable', 'type' => 'checkbox', 'default' => 'yes'],
            'title'       => ['title' => 'Title', 'type' => 'text', 'default' => 'Cryptocurrency'],
            'description' => ['title' => 'Description', 'type' => 'textarea',
                              'default' => 'Pay with BTC, ETH, USDT, and more.'],
            'base_url'    => ['title' => 'CryptoLink URL', 'type' => 'text'],
            'merchant_id' => ['title' => 'Merchant UUID', 'type' => 'text',
                              'description' => 'Found in Dashboard → Settings'],
            'api_token'   => ['title' => 'API Token', 'type' => 'password'],
            'hmac_secret' => ['title' => 'HMAC Secret', 'type' => 'password',
                              'description' => 'Set in Dashboard → Settings → Webhook'],
            'fiat_currency' => ['title' => 'Invoice Currency', 'type' => 'select',
                             'options' => ['USD' => 'USD', 'EUR' => 'EUR'],
                             'description' => 'Must match WooCommerce store currency'],
        ];
    }

    public function process_payment($order_id) {
        $order       = wc_get_order($order_id);
        $base_url    = $this->get_option('base_url');
        $merchant_id = $this->get_option('merchant_id');
        $currency    = $this->get_option('fiat_currency', 'USD'); // USD or EUR

        $payment_uuid = wp_generate_uuid4(); // unique per payment for idempotency
        $url = "{$base_url}/api/merchant/v1/merchant/{$merchant_id}/payment";

        $response = wp_remote_post($url, [
            'headers' => [
                'X-CRYPTOLINK-TOKEN' => $this->get_option('api_token'),
                'Content-Type'       => 'application/json',
            ],
            'body'    => json_encode([
                'id'          => $payment_uuid,
                'currency'    => $currency,
                'price'       => (float) $order->get_total(),
                'orderId'     => (string) $order_id,
                'description' => 'WooCommerce Order #' . $order_id,
                'redirectUrl' => $this->get_return_url($order),
            ]),
            'timeout' => 15,
        ]);

        if (is_wp_error($response)) {
            wc_add_notice('Payment error: ' . $response->get_error_message(), 'error');
            return;
        }

        $body = json_decode(wp_remote_retrieve_body($response), true);
        $code = wp_remote_retrieve_response_code($response);

        if ($code !== 201 || empty($body['paymentURL'])) {
            wc_add_notice('Payment gateway error. Please try again.', 'error');
            return;
        }

        $order->update_status('pending', 'Awaiting CryptoLink payment.');
        $order->update_meta_data('_cryptolink_payment_id', $body['id']);
        $order->save();

        return ['result' => 'success', 'redirect' => $body['paymentURL']];
    }
}

Webhook Handler

php — class-cryptolink-webhook.php
<?php
class WC_CryptoLink_Webhook {
    public static function handle() {
        $gateway     = new WC_CryptoLink_Gateway();
        $hmac_secret = $gateway->get_option('hmac_secret');
        $raw_body    = file_get_contents('php://input');
        $signature   = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
        $expected    = base64_encode(hash_hmac('sha512', $raw_body, $hmac_secret, true));

        if (!hash_equals($expected, $signature)) {
            status_header(401); exit('Invalid signature');
        }

        $event = json_decode($raw_body, true);
        $order = wc_get_order(intval($event['merchantOrderID'] ?? 0)); // your orderId
        if (!$order) { status_header(404); exit('Order not found'); }

        switch ($event['status']) {
            case 'success':
                if (!$order->is_paid()) {
                    $order->payment_complete($event['id']);
                    $order->add_order_note('CryptoLink: confirmed. Crypto: ' . $event['cryptoAmount'] . ' ' . $event['cryptoCurrency']);
                }
                break;
            case 'inProgress':
                $order->update_status('on-hold', 'CryptoLink: awaiting confirmation.');
                break;
            case 'failed':
                $order->update_status('failed', 'CryptoLink: payment failed.');
                break;
            case 'expired':
                $order->update_status('cancelled', 'CryptoLink: expired.');
                break;
        }

        status_header(200); exit(json_encode(['received' => true]));
    }
}
WooCommerce Order Currency

WooCommerce's get_total() returns the order total in your store currency. CryptoLink accepts USD or EUR. Set Invoice Currency in the plugin settings to match your WooCommerce store currency. CryptoLink automatically converts the fiat amount to the customer's chosen cryptocurrency at live exchange rates.

Self-Hosting Guide

CryptoLink runs on any modern Linux server. This guide covers a production deployment with Nginx, PostgreSQL, systemd services, and Let's Encrypt SSL. Estimated setup time: 30-60 minutes for an experienced Linux administrator.

1. Server Requirements

ComponentMinimumRecommended
OSUbuntu 20.04 / Debian 11Ubuntu 22.04 LTS
CPU1 vCPU2+ vCPU
RAM1 GB2 GB
Disk10 GB SSD20+ GB SSD
Go1.21+Latest stable
PostgreSQL14+15+
Node.js18+ (build only)20 LTS
Nginx1.18+Latest stable

2. Install System Dependencies

bash
# Update system
sudo apt update && sudo apt upgrade -y

# Install PostgreSQL
sudo apt install -y postgresql postgresql-contrib

# Install Nginx
sudo apt install -y nginx

# Install Go 1.21+
wget https://go.dev/dl/go1.22.0.linux-amd64.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.22.0.linux-amd64.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
source ~/.bashrc
go version  # verify

# Install Node.js 20 LTS (for building frontend only)
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs

3. Create System User & Directories

bash
# Create dedicated user (no login shell)
sudo useradd -r -s /sbin/nologin cryptolink

# Create directory structure
sudo mkdir -p /opt/cryptolink/{bin,config,logs}
sudo chown -R cryptolink:cryptolink /opt/cryptolink

4. Clone the Repository

bash
cd /opt/cryptolink
git clone https://your-repo-url/Cryptolink.git src
cd src

5. Configure PostgreSQL

bash
sudo -u postgres psql <<EOF
CREATE DATABASE cryptolink_db;
CREATE USER cryptolink_user WITH ENCRYPTED PASSWORD 'your_strong_password_here';
GRANT ALL PRIVILEGES ON DATABASE cryptolink_db TO cryptolink_user;
EOF

6. Configure cryptolink.yml

yaml — /opt/cryptolink/config/cryptolink.yml
oxygen:
  postgres:
    data_source: "postgres://cryptolink_user:[email protected]:5432/cryptolink_db?sslmode=disable&pool_max_conns=32"
    migrate_on_start: false  # run manually for first setup

  server:
    port: 3000
    cors:
      allow_origins:
        - https://yourdomain.com

  processing:
    webhook_base_path: https://yourdomain.com
    payment_frontend_base_path: https://yourdomain.com

  watcher:
    block_scan_depth: 50    # how many recent blocks to scan
    max_concurrency: 4      # parallel blockchain RPC calls
    enabled: true

  auth:
    email:
      enabled: true
      user_email: [email protected]

providers:
  trongrid:
    api_key: "your_trongrid_api_key"  # free at trongrid.io

No Third-Party API Keys Required (except TRON)

CryptoLink connects directly to blockchain networks via free public APIs (Blockstream for Bitcoin, public RPC nodes for EVM chains, Binance/CoinGecko for exchange rates). The only optional API key is for TronGrid if you enable TRON payments — get a free key at trongrid.io.

7. Run Database Migrations

bash
cd /opt/cryptolink/src

# Run migrations once manually
DB_MIGRATE_ON_START=true /opt/cryptolink/bin/cryptolink serve-web \
  --config=/opt/cryptolink/config/cryptolink.yml &
sleep 5 && kill %1

# After successful migration, set migrate: false in cryptolink.yml

8. Build Frontend (MUST come before Go build)

Critical Build Order

The Go binary embeds the frontend SPA at compile time using //go:embed dist/*. You MUST build the frontend FIRST, then build Go. Building in the wrong order will embed stale frontend assets.

bash
cd /opt/cryptolink/src

# Build merchant SPA (note VITE_ROOTPATH — it's required for the /merchants/ route)
cd ui-dashboard && npm install
VITE_ROOTPATH=/merchants/ npx vite build               # outputs ./dist  (merchant panel)

# Build admin SPA (separate Vite config, super-admin only)
npx vite build --config vite.admin.config.ts            # outputs ./dist-admin
cd ..

# Build payment SPA (customer-facing checkout)
cd ui-payment && npm install && npx vite build && cd ..

# Now build the Go binary (embeds the freshly built dist/ for the merchant SPA)
go build \
  -ldflags "-w -s -X 'main.gitVersion=v1.0.0' -X 'main.gitCommit=stable' -X 'main.embedFrontend=1'" \
  -o /opt/cryptolink/bin/cryptolink .

# Deploy static assets to your web root
sudo mkdir -p /var/www/cryptolink/{merchants,admin,p}
sudo cp -r ui-dashboard/dist/*       /var/www/cryptolink/merchants/
sudo cp -r ui-dashboard/dist-admin/* /var/www/cryptolink/admin/
# Admin SPA's nginx fallback is admin.html — make a copy as index.html too
sudo cp /var/www/cryptolink/admin/admin.html /var/www/cryptolink/admin/index.html
sudo cp -r ui-payment/dist/*          /var/www/cryptolink/p/

9. Nginx Configuration

nginx — /etc/nginx/sites-available/cryptolink
server {
    listen 443 ssl http2;
    server_name pay.yourdomain.com;

    ssl_certificate     /etc/letsencrypt/live/pay.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/pay.yourdomain.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         HIGH:!aNULL:!MD5;

    # Security headers
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Frame-Options DENY always;
    add_header X-Content-Type-Options nosniff always;
    add_header Referrer-Policy strict-origin-when-cross-origin always;

    root /var/www/cryptolink;

    # Merchant SPA
    location /merchants/ {
        try_files $uri $uri/ /merchants/index.html;
    }
    # Admin SPA (super-admin only — restrict by IP/auth at the proxy if desired)
    location /admin/ {
        try_files $uri $uri/ /admin/admin.html;
    }
    # Customer-facing payment page
    location /p/ {
        try_files $uri $uri/ /p/index.html;
    }
    # Legacy /dashboard/* URLs 301-redirect to /merchants/*
    location /dashboard/ {
        return 301 /merchants/$1$is_args$args;
    }

    # API — proxy to Go backend
    location /api/ {
        proxy_pass         http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_read_timeout 30s;
    }

    # Static assets
    location ~* \.(js|css|png|jpg|svg|ico|woff2?)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

server {
    listen 80;
    server_name pay.yourdomain.com;
    return 301 https://$host$request_uri;
}
bash — Enable and test Nginx config
sudo ln -s /etc/nginx/sites-available/cryptolink /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

10. SSL with Let's Encrypt

bash
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d pay.yourdomain.com
# Certbot will auto-renew. Test renewal:
sudo certbot renew --dry-run

11. Systemd Service

bash — /etc/systemd/system/cryptolink.service
[Unit]
Description=CryptoLink Payment Gateway
After=network.target postgresql.service
Requires=postgresql.service

[Service]
Type=simple
User=cryptolink
Group=cryptolink
WorkingDirectory=/opt/cryptolink
Environment=DB_MIGRATE_ON_START=false
ExecStart=/opt/cryptolink/bin/cryptolink serve-web \
  --config=/opt/cryptolink/config/cryptolink.yml
Restart=always
RestartSec=5
StandardOutput=append:/opt/cryptolink/logs/web.log
StandardError=append:/opt/cryptolink/logs/web.log

# Security hardening
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ReadWritePaths=/opt/cryptolink/logs

[Install]
WantedBy=multi-user.target
bash — Enable and start service
sudo systemctl daemon-reload
sudo systemctl enable cryptolink
sudo systemctl start cryptolink
sudo systemctl status cryptolink

# Tail logs
sudo tail -f /opt/cryptolink/logs/web.log

12. Firewall

bash — UFW firewall rules
sudo ufw allow 22/tcp    # SSH
sudo ufw allow 80/tcp    # HTTP (for ACME challenges)
sudo ufw allow 443/tcp   # HTTPS
# Block direct access to the Go backend (only Nginx should access it)
sudo ufw deny 8080/tcp
sudo ufw enable

13. Post-Install Checklist

Security Audit — 2025

CryptoLink underwent an independent security audit in early 2025 covering authentication, webhook integrity, API input validation, cryptographic implementations, infrastructure hardening, and the non-custodial architecture. The full findings are documented below.

Audit Scope

Auditor: Independent security review by a qualified application security researcher.
Scope: Go backend, React frontend, PostgreSQL schema, Nginx config, systemd hardening, webhook cryptography, HD wallet derivation logic.
Period: January–February 2025.
Result: 0 Critical, 2 High (fixed), 4 Medium (fixed), 3 Low (accepted/fixed), 5 Info.

Findings Summary

# Severity Title Category Status
CLK-01 High Timing Attack in Webhook Signature Verification Cryptography Fixed
CLK-02 High SSRF via Merchant-Supplied Callback URL Input Validation Fixed
CLK-03 Medium Sensitive Data in Application Error Logs Information Disclosure Fixed
CLK-04 Medium Missing Security Headers on API Responses Configuration Fixed
CLK-05 Medium No Rate Limiting on Authentication Endpoints Brute Force Fixed
CLK-06 Medium Missing CSRF Protection on Session Endpoints CSRF Fixed
CLK-07 Low Verbose Error Messages Returned to Client Information Disclosure Fixed
CLK-08 Low Default Configuration Enables DB Migration on Start Configuration Accepted Risk
CLK-09 Low Dependency on External Blockchain APIs (Blockstream, TronGrid) Third-Party Risk Mitigated
CLK-10 Info All SQL Queries Use Parameterized Statements SQL Injection Confirmed OK
CLK-11 Info bcrypt Password Hashing with Cost Factor 12 Authentication Confirmed OK
CLK-12 Info xpub Key Derivation — Private Key Never Stored Cryptography Confirmed OK
CLK-14 Info HMAC-SHA512 Used for Webhook Signatures Cryptography Confirmed OK

Finding Details

CLK-01 — Timing Attack in Webhook Signature Verification (High → Fixed)

Description: The original webhook signature comparison used a standard string equality operator (== in Go) rather than a constant-time comparison function. This allowed an attacker to determine valid HMAC signatures byte-by-byte through timing measurements.

Fix: Replaced with hmac.Equal(expected, provided) from the Go standard library, which performs a constant-time comparison regardless of where the strings diverge. Additionally, both the expected and provided signatures are base64-decoded before comparison to normalize encoding.

CLK-02 — SSRF via Merchant-Supplied Callback URL (High → Fixed)

Description: The callback_url field in payment creation accepted any URL, including http://localhost/, http://169.254.169.254/ (AWS metadata), and internal network addresses. This allowed a malicious merchant (on a multi-tenant installation) to probe internal services.

Fix: Added URL validation middleware that rejects callback URLs resolving to RFC 1918 private address ranges, loopback addresses, link-local addresses, and non-HTTP(S) schemes. Resolution is performed at payment creation time using Go's net.LookupHost().

CLK-03 — Sensitive Data in Application Error Logs (Medium → Fixed)

Description: Application error handling in some code paths logged full HTTP request context, including Authorization headers and request bodies that could contain API tokens or partial webhook payloads.

Fix: Implemented a structured logging sanitizer that strips known sensitive field names (token, key, secret, password, authorization) from all log output. Request body logging is now opt-in and disabled by default.

CLK-04 — Missing Security Headers (Medium → Fixed)

Description: API and frontend responses lacked standard browser security headers, potentially allowing clickjacking attacks and MIME type confusion.

Fix: Added middleware to set: Strict-Transport-Security, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, and a restrictive Content-Security-Policy on the dashboard frontend.

CLK-05 — No Rate Limiting on Auth Endpoints (Medium → Fixed)

Description: The login endpoint accepted unlimited requests, enabling brute-force password attacks and credential stuffing.

Fix: Added per-IP rate limiting with exponential backoff on all authentication endpoints: login, register, and API token creation. Limits: 10 attempts/minute per IP, with a 15-minute lockout after 30 failed attempts.

CLK-06 — Missing CSRF Protection (Medium → Fixed)

Description: Session-based dashboard endpoints (login, settings changes, token creation) lacked CSRF token validation, enabling cross-site request forgery attacks against authenticated merchant sessions.

Fix: Implemented CSRF token generation and validation using the double-submit cookie pattern for all state-changing session-based endpoints. API token endpoints are exempt (they use bearer token auth, which is inherently CSRF-resistant).

CLK-08 — DB Migration on Start (Low — Accepted Risk)

Description: The Go struct default for DB_MIGRATE_ON_START is true, meaning a server restart without explicitly setting the env var to false will attempt migrations. This could cause service disruption if migrations fail on a populated database.

Mitigation: The self-hosting guide documents explicitly setting DB_MIGRATE_ON_START=false in the systemd service environment. This is an accepted risk given the operational simplicity benefit. A future release will invert the default.

CLK-09 — Third-Party Blockchain API Dependency (Low — Mitigated)

Description: CryptoLink depends on external APIs for blockchain monitoring: Blockstream/mempool.space for Bitcoin, public RPC nodes for EVM chains, and TronGrid for TRON. An API outage would delay detection of incoming transactions but would NOT prevent funds from arriving.

Mitigation: The original single-point dependency on a third-party blockchain service was eliminated by switching to direct blockchain APIs with built-in fallbacks (Blockstream → mempool.space for Bitcoin, Binance → CoinGecko for exchange rates). Each API can only report transactions, not move funds. EVM chains use standard JSON-RPC, allowing operators to switch to self-hosted nodes.

Changelog

CryptoLink uses a single-decimal version scheme: 1.0, 2.0, 3.0, … Each release bundles backend, dashboard, payment SPA, and DB migrations together so a self-hosted instance moves between versions in one deploy. Tag the running version on your own infra by checking the X-CryptoLink-Version response header on any merchant API call (added from v1.0 onward).

v1.0 CURRENT

Released 2026-05-04 — first tagged release after the OxygenPay fork & rebrand.

Payments

  • Partial-payment top-up flow across all networks (BTC, EVM, TRON). Underpaid invoices stay open as partial; customer can complete from any wallet to the same address. Cumulative confirmed transfers tracked in transaction_fills with per-fill idempotency.
  • Per-fill expiry extension (+30m), hard-capped at original expiry +24h.
  • Webhook payload on partial status now includes receivedAmount, remainingAmount, idempotencyKey.
  • Reorg recheck job (every 5m) demotes fills whose hash is no longer on chain.
  • Admin POST /admin/payments/:id/reconcile endpoint for stale-tab pay recovery.

Architecture

  • Tatum fully removed — direct blockchain providers for BTC (Blockstream + mempool.space) and EVM (smart-contract collectors).
  • TRON clone-factory pattern deployed & verified on Tronscan; merchants deploy ~$0.50 EIP-1167 clones.
  • xpub / ypub / zpub auto-detection (SLIP-0132) with BIP44/49/84 path inference.
  • Watcher uses log-based EVM native detection (Received(address,uint256)) so dispersers and batch payers are caught.
  • Migrated off unmaintained go-hdwallet to btcd's official btcutil/hdkeychain.

Money & subscriptions

  • 26 fiat currencies with per-merchant fiat.currency setting.
  • Per-merchant fee.global volatility-buffer markup applied at lock + display.
  • 5-tier subscription system (Free / Starter / Growth / Business / Enterprise) with hard-enforced volume limits and 80/90/100% email alerts.

Security

  • Go toolchain bumped to 1.25.9 — closes 12 stdlib CVEs (crypto/x509, crypto/tls, html/template, os, net/url).
  • btcd 0.20.1-beta → 0.25.0 (witness-size critical, FindAndDelete high, consensus moderate).
  • oauth2, otel, pgx, vite, esbuild, @ant-design/pro-* family all bumped past their dependabot CVEs.
  • npm overrides for path-to-regexp ^8 and picomatch ^4 in dashboard.

UI

  • Matrix-Neon theme across merchant + admin dashboards (Ant Design v5 dark + custom --cl-* tokens).
  • Two separate SPAs: merchant panel (/merchants/*) and superadmin panel (/admin/*).
  • Payment SPA partial banner with received/remaining display and a fresh QR pre-filled with the missing amount.

Future releases bump the major: v2.0, v3.0, … The single-decimal scheme avoids semver bikeshedding for a self-hosted product where every release is bundled and migration-tested end-to-end.

Frequently Asked Questions

Answers to the most common questions from merchants evaluating CryptoLink.

Is CryptoLink really non-custodial? Can you steal my funds?

Structurally, no. CryptoLink only ever receives your xpub (extended public key), which allows it to generate deposit addresses but cannot sign transactions or move funds. Private keys live only in your hardware wallet or cold storage and never touch the CryptoLink server.

Even if the entire CryptoLink server were compromised — whether by an attacker, a rogue developer, or law enforcement — they would have no ability to spend your funds. They could at most see your payment history and customer data.

This is verifiable by reading the open-source code — every line is auditable under the MIT license.

What happens if a blockchain API goes down?

CryptoLink uses multiple APIs with fallback support. For Bitcoin, if Blockstream is unavailable, mempool.space is used automatically. For exchange rates, CoinGecko serves as fallback to Binance. If all APIs for a chain are down, payments remain in "pending" status until the API recovers. Your funds are never at risk — they arrive at your addresses regardless.

For critical production environments, consider monitoring your wallet addresses directly as a fallback. You can also manually mark payments as successful in the dashboard if you verify the transaction on a block explorer.

Can I use my own blockchain nodes?

Yes, for EVM chains. CryptoLink uses standard Ethereum JSON-RPC to communicate with EVM chains (ETH, MATIC, BSC, Arbitrum, Avalanche). You can point it at your own Geth/Nethermind/Erigon node by updating the RPC URLs in configuration. For Bitcoin, Blockstream and mempool.space are used via their free REST APIs.

How do the smart contract collectors work?

For EVM chains and TRON, each merchant deploys a lightweight smart contract "collector" via the dashboard. The admin deploys a factory contract once (~$16), and each merchant creates a cheap clone (~$0.50) using the EIP-1167 minimal proxy pattern. The clone is owned by the merchant's wallet — only they can withdraw funds. CryptoLink monitors the collector address for incoming payments but cannot move funds from it.

For EVM chains, connect MetaMask in the dashboard. For TRON, connect TronLink. The deployment is a one-time setup per chain.

How are refunds handled?

CryptoLink does not handle refunds automatically. Cryptocurrency transactions are irreversible by design. If you need to refund a customer, you must send the cryptocurrency manually from your wallet directly to the customer's address.

This is by design — it means no third party can issue refunds without your authorization, giving you full control. We recommend including a clear refund policy in your store that explains how crypto refunds work (typically sending equivalent crypto or store credit).

What is the subscription model? Are there per-transaction fees?

CryptoLink charges a flat monthly subscription based on your processing volume — there are zero per-transaction fees. Plans start at $0/month (Free) for up to $1,000 monthly volume, up to $199.99/month (Enterprise) for unlimited volume.

Network fees (gas, miner fees) are always paid by your customers — CryptoLink adds no markup. The only recurring cost is the subscription plan, payable in cryptocurrency through CryptoLink itself.

Is there a free tier?

Yes. The Free plan allows up to $1,000 USD in monthly payment volume with no cost. This is suitable for testing and small-volume merchants. Upgrading is seamless — your configuration, API tokens, and payment history carry over.

Additionally, because CryptoLink is open source, you can self-host without any subscription at all if you prefer to handle the operational overhead yourself.

Can multiple merchants share one CryptoLink installation?

Yes. CryptoLink supports multiple independent merchant accounts on a single installation. Each merchant has their own xpub keys, API tokens, payment history, and webhook configuration. They cannot see each other's data.

This makes CryptoLink suitable for running a payment-as-a-service business where you host infrastructure for multiple clients, each paying a subscription. The admin panel lets you manage all merchant accounts from a single interface.

How do I handle failed or underpaid payments?

If a customer sends the wrong amount, CryptoLink marks the payment as "failed" (by default). Your webhook handler receives the payment.failed event with the actual received amount included in the payload, so you can decide how to handle it (e.g., offer to top up or issue a manual refund).

You can configure an underpayment tolerance (e.g., accept payments within 1% of the required amount) in your merchant settings to reduce false failures due to exchange rate fluctuations.

Is KYC required to use CryptoLink?

No KYC is required by CryptoLink itself. It is self-hosted software — you run it on your own server. There is no central authority to satisfy compliance requirements.

Note that your own local laws may require KYC/AML compliance depending on your business type and jurisdiction. CryptoLink provides the tools to collect customer email addresses and order IDs, which you can use to implement your own compliance workflows. Consult a legal advisor for your specific situation.

How do I generate an xpub key from my wallet?

Ledger: Open Ledger Live → select account → click ⋯ → Advanced → Extended public key.

Trezor: Open Trezor Suite → Accounts → click the account → Show public key (or "Account details").

MetaMask / Ethereum: MetaMask does not natively expose xpub. Use the xpub extractor tool at /xpub-extractor.html on your CryptoLink installation, which uses your MetaMask-connected account to derive the xpub offline in your browser.

Ian Coleman's tool: For BIP39 seed-based wallets, use iancoleman.io/bip39 offline to derive xpubs from your seed phrase.

Can I accept payments without redirecting customers off my site?

The default flow redirects customers to the CryptoLink-hosted payment page (on your own domain). This is the recommended approach as it keeps the payment UI maintained and updated automatically.

For a fully embedded experience, you can load the payment page in an iframe within your checkout flow. The payment page is designed to work in frames and communicates status changes via postMessage. Full documentation for embedded checkout is coming in a future release.