Accept your first crypto payment
One project, your wallet, one API key. Create an invoice and redirect your buyer.
1. Configure your project
Create a project for your store. Add your merchant-owned payment wallet, create an API key in Developers, and register a public HTTPS webhook. Save the signing secret and keyId once. In Settings add success/cancel URLs and exact allowed domains. No projectId, walletId or internal assetId is needed in normal payment requests.
2. Create an invoice — curl
Set CRYPTO_PAY_API_KEY and CRYPTO_PAY_API_URL on your store server. Persist one Idempotency-Key per payment attempt. Same key and logical request returns the same invoice, even after wallet changes. Different payload or another key for the same externalId conflicts. IDs are independent across projects.
curl --fail-with-body -X POST "$CRYPTO_PAY_API_URL/v1/invoices" \
-H "X-Api-Key: $CRYPTO_PAY_API_KEY" \
-H "Idempotency-Key: order-58391" \
-H "Content-Type: application/json" \
-d '{"amount":"149.99","asset":"USDT","network":"TRON","externalId":"ORDER-58391"}'
Node.js / TypeScript
Call from your store backend. Amounts are decimal strings. Persist the returned invoiceId with your order.
// Store backend, Node.js 20+. Store the response with the order before redirecting.
type PaymentRequest = {
amount: string;
asset: 'USDT' | 'USDC';
network: 'TRON' | 'ETHEREUM' | 'BSC' | 'POLYGON';
externalId: string;
};
export async function createInvoice(order: PaymentRequest, idempotencyKey: string) {
const response = await fetch(`${process.env.CRYPTO_PAY_API_URL}/v1/invoices`, {
method: 'POST',
headers: {
'X-Api-Key': process.env.CRYPTO_PAY_API_KEY!,
'Idempotency-Key': idempotencyKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(order),
});
const invoice = await response.json();
if (!response.ok) throw new Error(`${invoice.error.code}: ${invoice.error.message}`);
return invoice as { invoiceId: string; externalId: string; status: string; checkoutUrl: string; expiresAt: string };
}
PHP
Requires the PHP cURL extension. Keep API credentials server-side.
<?php
// Run only on your store server. Persist one idempotency key per checkout attempt.
function createInvoice(array $order, string $idempotencyKey): array {
$ch = curl_init(getenv('CRYPTO_PAY_API_URL') . '/v1/invoices');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Api-Key: ' . getenv('CRYPTO_PAY_API_KEY'),
'Idempotency-Key: ' . $idempotencyKey,
],
CURLOPT_POSTFIELDS => json_encode($order, JSON_THROW_ON_ERROR),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($raw === false) throw new RuntimeException('Connection failed; retry with the same key');
$result = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
if ($status < 200 || $status >= 300) throw new RuntimeException($result['error']['code'] . ': ' . $result['error']['message']);
return $result;
}
$invoice = createInvoice([
'amount' => '149.99', 'asset' => 'USDT', 'network' => 'TRON', 'externalId' => 'ORDER-58391',
], 'order-58391');
// Persist $invoice['invoiceId'] with your order, then navigate to $invoice['checkoutUrl'].
// Never fulfill an order based only on the success redirect.
3. Redirect the buyer
Navigate to checkoutUrl. The checkout displays the exact amount to send in one transaction. Changing your wallet only affects new payments. The first wallet becomes default for its network; multiple wallets require a default. Old invoices remain bound to their original addresses.
4. Verify invoice.paid
Never fulfill an order based only on the success redirect. Use the raw-body verifier below before JSON parsing. Match the signed invoiceId and externalId to your stored order. X-Event-Id and X-Event-Type carry authenticated event identity/type. Timestamp and attempt change on retries; identity does not.
'use strict';
const { createHmac, timingSafeEqual } = require('node:crypto');
// Merchant-side example. Select a retained secret by authenticated keyId candidate,
// then verify before trusting fields. Preserve the exact request body bytes.
function verifyWebhook(
secret,
headers,
rawBody,
now = Math.floor(Date.now() / 1000),
) {
const h = Object.fromEntries(
Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]),
);
if (
[
'x-webhook-version',
'x-event-id',
'x-delivery-id',
'x-event-type',
'x-timestamp',
'x-key-id',
'x-attempt',
'x-idempotency-key',
'x-signature-sha256',
].some((key) => typeof h[key] !== 'string')
)
return false;
const timestamp = h['x-timestamp'],
attempt = h['x-attempt'],
signature = h['x-signature-sha256'];
if (
h['x-webhook-version'] !== '2' ||
!/^\d{1,12}$/.test(timestamp || '') ||
!/^\d{1,10}$/.test(attempt || '') ||
!/^[0-9a-f]{64}$/.test(signature || '') ||
!h['x-event-id'] ||
!h['x-delivery-id'] ||
!h['x-event-type'] ||
!h['x-key-id'] ||
h['x-idempotency-key'] !== h['x-event-id']
)
return false;
const input = JSON.stringify([
'vaultless.webhook',
2,
h['x-event-id'],
h['x-delivery-id'],
h['x-event-type'],
timestamp,
h['x-key-id'],
attempt,
Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : rawBody,
]);
const expected = createHmac('sha256', secret).update(input, 'utf8').digest();
return (
timingSafeEqual(expected, Buffer.from(signature, 'hex')) &&
Math.abs(now - Number(timestamp)) <= 300
);
}
module.exports = { verifyWebhook };
5. Fulfill exactly once
After verification, transactionally deduplicate (project, eventId), update your order and record an idempotent fulfillment obligation. Return 2xx for already processed events. Execute external fulfillment idempotently. Ignore integration.test for orders. As an alternative, verify GET /v1/invoices/:invoiceId on your backend with the same API key; require PAID and the expected externalId.
When redirect arrives first
Show “Confirming your payment...” and poll your own backend briefly. Webhook processing can lag the redirect. invoiceId in the browser is navigation only. Do not mark failure just because the webhook has not arrived. Cancel/return links only navigate away.
Test integration
Send Test Webhook in Developers. integration.test uses secure webhook v2 and cannot fulfill a real order. The checklist completes the test step only after successful delivery. A full money-free sandbox is deferred until it has isolated data, keys and payment simulation; live invoices cannot be marked paid manually.
Wrong amount or expired window
Send exactly the displayed amount. No partial accumulation or approximate matching. If you already sent another amount, contact the merchant for review before sending again. A shared address cannot prove which invoice an unmatched payment was intended for. Expired checkout hides QR and provides a safe return link.