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.
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
- True non-custody — BTC uses xpub HD derivation; EVM and TRON chains use merchant-owned smart contract collectors. Private keys never touch the server.
- Self-hosted — deploy on any Linux server with full control
- Open source — MIT license, every line of code is fully auditable
- Zero per-transaction fees — flat subscription plans starting at $0/month
- 7 blockchains, 17 currencies — BTC, ETH, MATIC, BSC, TRON, Arbitrum, Avalanche plus USDT/USDC on supported chains
- No third-party middleware — direct blockchain APIs (Blockstream, go-ethereum RPC, TronGrid) with no intermediary dependencies
- Smart contract collectors — EIP-1167 clone factory pattern for cheap, non-custodial EVM and TRON payment addresses
- Multi-merchant — one installation can host multiple independent merchants
- Real-time webhooks — HMAC-signed event delivery for every payment state change
- Payment links — reusable, shareable links for donations or flexible-amount payments
- Modern dashboard — React SPA with analytics, payment management, and API token management
- Multi-fiat invoicing — price in any of 26 fiat currencies (USD, EUR, GBP, JPY, …) with optional volatility-fee markup
- Underpayment handling — automatic detection, configurable grace period for top-up
- Subscription enforcement — plan limits checked server-side; HTTP 402 on overage with upgrade URL
Subscription Plans
| Plan | Price | Monthly Volume | Merchants |
|---|---|---|---|
| Free | $0/mo | Up to $1,000 | 1 |
| Starter | $9.99/mo | Up to $10,000 | 1 |
| Growth | $29.99/mo | Up to $50,000 | 3 |
| Business | $79.99/mo | Up to $250,000 | 10 |
| Enterprise | $199.99/mo | Unlimited | Unlimited |
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
- Your private keys — they never leave your hardware wallet, cold storage, or browser extension.
- Your legal identity — no KYC. Sign-up requires only an email address, which you can rotate.
- Your customer list, order data, or business volume — all stored only in your self-hosted database.
- The mapping between merchant accounts and real-world identities — CryptoLink-the-software has no such record.
What IS publicly visible (because all blockchains are public ledgers)
- Deployed smart-contract addresses themselves. EVM and TRON contracts live on a public chain; their bytecode can be verified on block explorers (Etherscan, Tronscan). This is unavoidable for any on-chain payment system.
- Each collector clone's address and its owner. When a merchant deploys a clone, the factory emits a
CloneCreated(owner, clone)event. Anyone watching the chain can see the owner address — but no name, email, or business name is attached. - Payment transactions to your address. Every blockchain transaction is public. This is true of all on-chain payments, custodial or not.
- The CryptoLink open-source code, including the Solidity contracts. Publishing them is a feature, not a leak — it lets anyone verify there's no backdoor.
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
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).
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.
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.
pending → inProgress (mempool/broadcast) → success (confirmed) or failed/expired. The customer's payment page polls for updates and shows confirmation in real-time.
X-Signature header and fulfill the order.
Payment States
| Status | Description | Trigger |
|---|---|---|
| pending | Payment created, awaiting transaction | Created via API |
| locked | Customer picked a currency; awaiting on-chain transfer | Customer action on payment page |
| inProgress | Transaction detected on-chain, awaiting confirmations | Address watcher (event-based for EVM, balance-based for BTC, log-based for tokens) |
| success | Transaction confirmed; merchant webhook delivered | Address watcher (confirmed) |
| failed | Transaction failed or wrong amount | Address watcher / system check |
| expired | Payment 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.
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:
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:
| Prefix | Standard | Address Format | Derivation Path | Example 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.
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.
| Wallet | Export Format | Notes |
|---|---|---|
| Exodus | zpub / ypub / xpub | Settings → Developer Menu → Export. All three formats available. |
| Electrum | zpub | Wallet → Information → Master Public Key. Correct format for SegWit. |
| Trezor Suite | zpub | Account → Show Public Key. Correct format for SegWit accounts. |
| Ledger Live | xpub only | Always exports xpub even for SegWit accounts. Use Electrum or Sparrow to export zpub instead. |
| Coldcard | zpub | Settings → Advanced → Export → XPUB. Uses correct SLIP-0132 prefixes. |
| Sparrow Wallet | zpub | Settings → Show Master Public Key. Always correct format. |
| BlueWallet | zpub | Wallet → Details → Show XPUB. Uses correct format. |
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):
// 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.
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:
| Entity | Trust Required? | Reason |
|---|---|---|
| CryptoLink code | ✓ Yes | Open source — you can read and audit every line |
| Your server | ✓ Yes | You control it — standard server hardening applies |
| Blockchain APIs (Blockstream, TronGrid) | ⚡ Partial | Trusted to report transactions accurately; cannot move your funds. Multiple fallback sources reduce single-point risk. |
| CryptoLink developers | ✗ No | No access to your server, keys, or funds |
| CryptoLink company | ✗ No | Self-hosted — no ongoing relationship with any company |
| Payment processor | ✗ No | There 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
- Non-custodial by design — the architecture makes custody of merchant funds structurally impossible
- Parameterized SQL everywhere — all database queries use prepared statements; SQL injection is eliminated at the framework level
- bcrypt password hashing — merchant passwords are hashed with bcrypt (cost factor 12)
- Constant-time HMAC comparison — webhook signatures use
hmac.Equal()(Go) to prevent timing attacks - Security headers — HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and CSP set on all responses
- Rate limiting — auth and payment endpoints are rate-limited to prevent brute force and DoS
- SSRF protection — merchant-supplied callback URLs are validated to prevent Server-Side Request Forgery
- Sanitized error logs — sensitive data (tokens, keys, passwords) is never written to logs
- CSRF protection — session-based endpoints include CSRF token validation
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.
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.
- Exodus: Settings → Developer Menu → Export xpub for Bitcoin. Use the
zpubkey for Native SegWit. - Electrum: Wallet → Information → Master Public Key. Exports
zpubfor SegWit wallets. - Trezor: Trezor Suite → Account → Show Public Key. Exports
zpubfor SegWit accounts. - Ledger: Ledger Live → Account → Advanced → Extended Public Key. Note: Ledger exports as
xpubeven for SegWit accounts — use Electrum or Sparrow to export the correctzpubformat. - Coldcard: Settings → Advanced → Export → XPUB. Uses correct SLIP-0132 prefixes.
- Xpub Extractor: Use the offline tool at
https://yourdomain.com/xpub-extractor.html
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:
- EVM Chains: Connect MetaMask, click "Deploy Collector" — costs ~$0.50 in gas
- TRON: Connect TronLink browser extension, click "Deploy Collector" — costs ~150 TRX (~$0.50)
The collector contract is owned by your wallet — only you can withdraw funds from it.
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
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:
{
"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
// 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:
curl https://yourdomain.com/api/merchant/v1/merchant/{merchantId}/payment \
-H "X-CRYPTOLINK-TOKEN: cl_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
Creating API Tokens
- Log in to the merchant dashboard at
/merchants/login - Click your account name in the top-right corner
- Navigate to API Tokens
- Click Create Token and give it a descriptive name
- Copy the token immediately — it will not be shown again
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 Status | Error | Cause |
|---|---|---|
401 | unauthorized | Missing or invalid token header |
403 | forbidden | Token valid but lacks permission for this resource |
429 | rate_limited | Too 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:
https://yourdomain.com/api/merchant/v1/merchant/{merchantId}
Example for merchant UUID 550e8400-e29b-41d4-a716-446655440000:
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
Create Payment
POST /api/merchant/v1/merchant/{merchantId}/payment
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
| Field | Type | Required | Description |
|---|---|---|---|
id | string (UUID v4) | Required | Unique UUID you generate per payment — used for idempotency. Re-submitting with the same UUID returns the existing payment. |
currency | string | Required | Fiat currency: "USD" or "EUR". The customer chooses which crypto to pay with on the payment page. |
price | number | Required | Amount in fiat currency (e.g. 100.00). Min: 0.01 |
orderId | string | Optional | Your internal order reference — returned in webhook payloads for matching |
description | string | Optional | Human-readable description (max 128 chars) |
redirectUrl | string | Optional | URL shown to customer after payment detected. Defaults to merchant website. |
isTest | boolean | Optional | Set true for testnet payments (default: false) |
{
"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
{
"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.
{
"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
| Parameter | Type | Default | Description |
|---|---|---|---|
skip | integer | 0 | Number of records to skip (for pagination) |
limit | integer | 20 | Maximum records to return (max: 100) |
status | string | all | Filter by status: pending, inProgress, success, failed, expired |
currency | string | all | Filter by currency ticker |
Error Responses
| Status | Code | Description |
|---|---|---|
400 | validation_error | Invalid request body or parameters |
401 | unauthorized | Missing or invalid API token |
402 | limit_exceeded | Subscription plan limit exceeded (payment count, volume, or merchant count). Response includes upgrade_url. |
404 | not_found | Payment ID does not exist |
429 | rate_limited | Rate limit exceeded |
500 | internal_error | Server error (check server logs) |
Payment Links API
Payment links are reusable, shareable URLs that accept payments without a fixed amount. Ideal for donations, tips, open-ended invoices, or any scenario where the customer specifies the amount.
Endpoints
Create Payment Link
POST /api/merchant/v1/merchant/{merchantId}/payment-link
{
"name": "Donation Link",
"description": "Support our open-source project",
"currency": "USDT_TRON",
"redirect_url": "https://example.com/thank-you",
"successMessage": "Thank you for your payment!"
}
{
"id": "7f3a9b2c-1234-5678-abcd-ef0123456789",
"url": "https://yourdomain.com/p/7f3a9b2c-1234-5678-abcd-ef0123456789",
"name": "Donation Link",
"description": "Support our open-source project",
"currency": "USDT_TRON",
"status": "active",
"total_payments": 0,
"total_amount_usd": "0.00",
"created_at": "2025-06-01T12:00:00Z"
}
Payment Link Fields
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Required | Internal name for identifying the link in your dashboard |
description | string | Optional | Description shown to the customer on the payment page |
currency | string | Required | Accepted currency ticker |
redirect_url | string | Optional | Post-payment redirect URL |
successMessage | string | Optional | Message shown to customer after payment (alternative to redirect) |
Payment links can be used unlimited times. Each time a customer visits the link and completes a payment, a new payment record is created. The link URL remains constant and can be embedded in emails, social media, or your website permanently.
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
| Event | Trigger | Action Recommended |
|---|---|---|
payment.pending | Payment created (initial state) | Display "awaiting payment" UI |
payment.inProgress | Transaction broadcast to mempool | Show "payment processing" status |
payment.success | Transaction confirmed on-chain | Fulfill the order |
payment.failed | Transaction failed or underpaid | Notify customer, offer retry |
payment.expired | Payment window elapsed (24h default) | Cancel order, notify customer |
Webhook Payload
{
"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
}
| Field | Description |
|---|---|
id | The payment UUID — same as the id you provided when creating the payment |
status | Current payment status: pending, inProgress, success, failed |
merchantOrderID | Your orderId from payment creation — use this to match to your order |
amount | Invoice amount in fiat (e.g. "100.00") |
currency | Fiat currency: "USD" or "EUR" |
cryptoAmount | Crypto amount the customer paid/is expected to pay |
cryptoCurrency | Crypto ticker used (e.g. "ETH_USDT", "BTC", "USDT_TRON") |
selectedBlockchain | Blockchain network (e.g. "ETH", "BTC", "TRON") |
customerEmail | Customer email if provided |
isTest | True if this is a testnet payment |
linkId | Payment 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.
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
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]);
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 });
});
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
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.
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
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
| Field | Type | Required | Description |
|---|---|---|---|
blockchain | string | Required | Blockchain identifier: "BTC" |
xpub | string | Required | Extended public key in any format: xpub..., ypub..., or zpub... |
derivationPath | string | Optional | Override derivation path (e.g. m/84'/0'/0'). Auto-detected from key format if omitted. |
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..."
}'
{
"uuid": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"blockchain": "BTC",
"derivationPath": "m/84'/0'/0'",
"lastDerivedIndex": -1,
"createdAt": "2026-03-14T12:00:00Z"
}
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.
[
{
"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.
{
"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.
[
{
"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
| Status | Code | Description |
|---|---|---|
400 | validation_error | Invalid key format, missing fields, or wallet already exists for this blockchain |
401 | unauthorized | Missing or invalid API token |
404 | not_found | Wallet ID does not exist |
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)
| Currency | Ticker | Network | Wallet Type | Detection | Notes |
|---|---|---|---|---|---|
| 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)
| Currency | Ticker | Network | Standard | Detection | Notes |
|---|---|---|---|---|---|
| Ethereum | ETH | Ethereum | Native | go-ethereum RPC | Smart contract collector |
| Tether USD (ERC-20) | USDT_ETH | Ethereum | ERC-20 | go-ethereum RPC | Same collector as ETH |
| USD Coin (ERC-20) | USDC_ETH | Ethereum | ERC-20 | go-ethereum RPC | Same collector as ETH |
| Polygon | MATIC | Polygon PoS | Native | go-ethereum RPC | Low-fee EVM chain |
| USDT (Polygon) | USDT_MATIC | Polygon PoS | ERC-20 | go-ethereum RPC | Same collector as MATIC |
| USDC (Polygon) | USDC_MATIC | Polygon PoS | ERC-20 | go-ethereum RPC | Same collector as MATIC |
| BNB | BNB | BNB Smart Chain | Native | go-ethereum RPC | Binance ecosystem |
| USDT (BSC) | USDT_BSC | BNB Smart Chain | BEP-20 | go-ethereum RPC | Same collector as BNB |
| Arbitrum ETH | ARB | Arbitrum One | Native | go-ethereum RPC | L2 rollup, low fees |
| USDT (Arbitrum) | USDT_ARB | Arbitrum One | ERC-20 | go-ethereum RPC | Same collector as ARB |
| USDC (Arbitrum) | USDC_ARB | Arbitrum One | ERC-20 | go-ethereum RPC | Same collector as ARB |
| Avalanche | AVAX | Avalanche C-Chain | Native | go-ethereum RPC | Fast finality |
| USDT (Avalanche) | USDT_AVAX | Avalanche C-Chain | ERC-20 | go-ethereum RPC | Same collector as AVAX |
| USDC (Avalanche) | USDC_AVAX | Avalanche C-Chain | ERC-20 | go-ethereum RPC | Same collector as AVAX |
TRON (Smart Contract Collector)
| Currency | Ticker | Network | Standard | Detection | Notes |
|---|---|---|---|---|---|
| TRON | TRX | TRON Network | Native | TronGrid API | Clone factory collector via TronLink |
| Tether USD (TRC-20) | USDT_TRON | TRON Network | TRC-20 | TronGrid API | Most 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).
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)
| Step | Operation | Example |
|---|---|---|
| 1 | Merchant creates EUR invoice | €85.00 EUR |
| 2 | EUR → USD (fiat-to-fiat conversion) | $92.35 USD |
| 3 | USD → crypto (via Binance/CoinGecko rates) | 92.35 USDT or 0.00138 ETH |
| 4 | Crypto amount shown on payment page | Customer sends exact crypto amount |
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
- Merchant sets a markup (e.g. 2%) for ETH in their dashboard
- Customer creates a $100 USD invoice
- Customer sees: $100 on the payment page (no fee displayed)
- Crypto amount calculated: equivalent of $102 (100 + 2%)
- Merchant receives $102 worth of crypto — the 2% covers conversion & volatility risk
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:
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
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
'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
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
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
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]);
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
npm install axios express dotenvCryptoLink Client
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
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
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
pip install requests flask python-dotenvCryptoLink Client
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
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
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
wp-content/plugins/cryptolink-woocommerce/
├── cryptolink-woocommerce.php # Plugin bootstrap
└── includes/
├── class-cryptolink-gateway.php
└── class-cryptolink-webhook.phpPlugin Bootstrap
<?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 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 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'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
| Component | Minimum | Recommended |
|---|---|---|
| OS | Ubuntu 20.04 / Debian 11 | Ubuntu 22.04 LTS |
| CPU | 1 vCPU | 2+ vCPU |
| RAM | 1 GB | 2 GB |
| Disk | 10 GB SSD | 20+ GB SSD |
| Go | 1.21+ | Latest stable |
| PostgreSQL | 14+ | 15+ |
| Node.js | 18+ (build only) | 20 LTS |
| Nginx | 1.18+ | Latest stable |
2. Install System Dependencies
# 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
# 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
cd /opt/cryptolink
git clone https://your-repo-url/Cryptolink.git src
cd src
5. Configure PostgreSQL
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
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
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
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)
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.
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
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;
}
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
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
[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
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
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
- Login at
https://pay.yourdomain.com/merchants/login - Register your merchant account (first account = admin)
- Add your xpub keys in Settings → Wallets
- Generate your first API token
- Create a test payment and verify webhook delivery
- Configure volume alert emails in Settings → Notifications
- Set up log rotation:
sudo logrotate -d /etc/logrotate.d/cryptolink
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.
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 intransaction_fillswith per-fill idempotency. - Per-fill expiry extension (+30m), hard-capped at original expiry +24h.
- Webhook payload on
partialstatus now includesreceivedAmount,remainingAmount,idempotencyKey. - Reorg recheck job (every 5m) demotes fills whose hash is no longer on chain.
- Admin
POST /admin/payments/:id/reconcileendpoint 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-hdwalletto btcd's officialbtcutil/hdkeychain.
Money & subscriptions
- 26 fiat currencies with per-merchant
fiat.currencysetting. - Per-merchant
fee.globalvolatility-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). btcd0.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
overridesforpath-to-regexp ^8andpicomatch ^4in 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.