Move from Cryptomus or Heleket without rewriting your order flow.
VeilNebula exposes a compatibility-oriented invoice input, but its canonical API is explicit: POST /v1/payments, x-api-key authentication, signed mutating requests, hosted checkoutUrl, and signed webhooks.
API mapping
VeilNebula accepts the Cryptomus-style aliases below (camelCase, and snake_case where listed) on POST /v1/payments and folds them into the canonical fields. If both are sent, the canonical field wins.
| Cryptomus / Heleket | VeilNebula | What to change |
|---|---|---|
| POST https://api.cryptomus.com/v1/payment | POST https://api.veilnebu.la/v1/payments | Creates the invoice. A 201 response returns the invoice object. |
| merchant + sign headers | x-api-key, x-ncp-timestamp, x-ncp-signature | x-api-key is the project API key. POST requests are signed: hex HMAC-SHA256, keyed with the API key, over timestamp.METHOD.path.rawBody, where timestamp is ISO 8601 and within the server window. |
| amount + currency | amountMinor + currencyCode | amountMinor is a string of digits in minor units of currencyCode, not a decimal: 15.00 USD is "1500". |
| order_id | externalId | Your order id. Fetch the invoice later with GET /v1/payments/by-order/{orderId}. |
| to_currency | settlementAssetCode (alias toCurrency / to_currency) | USDT or USDC. VeilNebula does not offer BTC or fiat settlement. |
| lifetime | lifetimeSeconds (alias lifetime) | Integer seconds, 60 to 2,592,000. |
| currencies / except_currencies | allowedAssets / excludedAssets (aliases currencies, exceptCurrencies, except_currencies) | Arrays of asset codes that narrow the hosted checkout. |
| accuracy_payment_percent | underpaidToleranceBps (alias accuracyPaymentPercent / accuracy_payment_percent) | The alias takes an integer percent 0 to 100 (no decimals); it is multiplied by 100 and capped at 5000 bps. |
| subtract | subtract | Boolean, not a 0 to 100 percentage. true deducts the fee from amountMinor; false (default) adds it on top. |
| is_refresh | isRefresh (alias is_refresh) | Re-quotes and extends an existing invoice only when the same idempotency key and request body are sent again. |
| url_success / url_return | successUrl / cancelUrl | Absolute http(s) URLs for the customer redirect. |
| url_callback | Project webhook endpoint | There is no per-invoice callback URL. Register the endpoint once for the project in the dashboard. |
| uuid / url (response) | id / checkoutUrl | Store id; redirect the customer to checkoutUrl. |
| POST /v1/payment/info | GET /v1/payments/{id} | Read-only status lookup. GET requests need x-api-key only, no request signature. |
| Retries | idempotency-key header (or idempotencyKey body field) | Use one stable key per order. The same key with the same body returns the existing invoice; a different body returns 409. |
Webhook mapping
VeilNebula sends a flat JSON body with event, eventId and the invoice fields. Verify the raw body against x-nebula-signature (t=...,v1=...) before parsing it, and reject timestamps more than 300 seconds old.
| Cryptomus status | VeilNebula event | Handling |
|---|---|---|
| sign (body field) | x-nebula-signature (header) | Cryptomus signs md5(base64(json) + API key) inside the body. VeilNebula sends t=<unix seconds>,v1=<hex HMAC-SHA256 of t + "." + raw body> keyed with the endpoint secret. |
| confirm_check | payment.confirming | Deposit seen on chain, waiting for confirmations. |
| paid | payment.paid | Treat as the successful payment transition. |
| paid_over | payment.overpaid | Paid in full with an overpayment; fulfil and reconcile the difference. |
| wrong_amount | payment.partially_paid | Keep the order open; compare receivedInvoiceAmountMinor with invoiceAmountMinor. |
| cancel | payment.expired / payment.cancelled | Stop waiting for this invoice. |
| fail / system_fail | payment.failed | Route to your existing payment-failure path. |
| refund_process / refund_paid / refund_fail | None | Refunds are not live yet. |
Migration steps
- Create an account and project. In the dashboard at
https://app.veilnebu.la, create a sandbox project first, then a live project when the integration is verified. - Issue a project API key. Store it as
VEILNEBULA_API_KEY; never expose it in browser code. - Swap the invoice endpoint. Replace the provider create-invoice call with
POST https://api.veilnebu.la/v1/payments. - Update headers. Replace
merchantandsignwithx-api-key,x-ncp-timestamp,x-ncp-signature, and a stableidempotency-key. - Register the webhook endpoint. Add your URL as a project webhook endpoint instead of sending
url_callbackper invoice, and store its signing secret asVEILNEBULA_WEBHOOK_SECRET. - Test the checkout and webhook. Create a sandbox invoice, redirect to
checkoutUrl, and use the dashboard webhook test before processing live orders. - Go live. Change credentials and endpoint configuration only after replay protection and signature verification pass.
Node.js
The example signs the exact JSON bytes sent on the wire and keeps the raw webhook body for verification.
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const app = express();
const apiKey = process.env.VEILNEBULA_API_KEY;
const webhookSecret = process.env.VEILNEBULA_WEBHOOK_SECRET;
function signedHeaders(method, path, body, idempotencyKey) {
const timestamp = new Date().toISOString();
const signature = createHmac('sha256', apiKey)
.update([timestamp, method, path, body].join('.'))
.digest('hex');
return {
'content-type': 'application/json',
'x-api-key': apiKey,
'x-ncp-timestamp': timestamp,
'x-ncp-signature': signature,
'idempotency-key': idempotencyKey,
};
}
function verifyWebhook(rawBody, header) {
const parts = Object.fromEntries(
header.split(',').map((part) => part.trim().split('=', 2)),
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = Buffer.from(
createHmac('sha256', webhookSecret).update(parts.t + '.' + rawBody).digest('hex'),
);
const received = Buffer.from(parts.v1);
return expected.length === received.length && timingSafeEqual(expected, received);
}
app.post('/pay', express.json(), async (req, res) => {
const orderId = String(req.body.orderId);
const path = '/v1/payments';
const body = JSON.stringify({
amountMinor: '250000', // 2500.00 USD in cents
currencyCode: 'USD',
settlementAssetCode: 'USDT',
externalId: orderId,
description: 'Order ' + orderId,
successUrl: 'https://merchant.example/paid',
cancelUrl: 'https://merchant.example/cancelled',
});
const response = await fetch('https://api.veilnebu.la' + path, {
method: 'POST',
headers: signedHeaders('POST', path, body, 'order-' + orderId),
body,
});
if (!response.ok) return res.sendStatus(502);
const invoice = await response.json();
return res.redirect(303, invoice.checkoutUrl);
});
app.post('/webhooks/veilnebula', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
if (!verifyWebhook(rawBody, req.get('x-nebula-signature') ?? '')) {
return res.sendStatus(400);
}
const event = JSON.parse(rawBody);
if (event.event === 'payment.paid') {
// Deduplicate on event.eventId, then fulfil the order in event.externalId.
}
return res.sendStatus(204);
});PHP
The PHP example uses cURL and hash_hmac with the same request-signature formula.
<?php
function createInvoice(string $apiKey, string $orderId, string $amountMinor): array
{
$path = '/v1/payments';
$body = json_encode([
'amountMinor' => $amountMinor, // minor units: '250000' = 2500.00 USD
'currencyCode' => 'USD',
'settlementAssetCode' => 'USDT',
'externalId' => $orderId,
'description' => 'Order ' . $orderId,
'successUrl' => 'https://merchant.example/paid',
'cancelUrl' => 'https://merchant.example/cancelled',
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$timestamp = gmdate('Y-m-d\TH:i:s\Z');
$signature = hash_hmac('sha256', implode('.', [$timestamp, 'POST', $path, $body]), $apiKey);
$ch = curl_init('https://api.veilnebu.la' . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => [
'content-type: application/json',
'x-api-key: ' . $apiKey,
'x-ncp-timestamp: ' . $timestamp,
'x-ncp-signature: ' . $signature,
'idempotency-key: order-' . $orderId,
],
CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($ch);
if ($response === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status < 200 || $status >= 300) throw new RuntimeException('VeilNebula API returned ' . $status);
return json_decode($response, true, flags: JSON_THROW_ON_ERROR);
}
function verifyWebhook(string $raw, string $signatureHeader, string $secret): bool
{
if (!preg_match('/t=(\d+),v1=([0-9a-f]+)/', $signatureHeader, $matches)) return false;
if (abs(time() - (int) $matches[1]) > 300) return false;
$expected = hash_hmac('sha256', $matches[1] . '.' . $raw, $secret);
return hash_equals($expected, $matches[2]);
}
function handleWebhook(string $raw, string $signatureHeader, string $secret): void
{
if (!verifyWebhook($raw, $signatureHeader, $secret)) {
http_response_code(400);
return;
}
$event = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
if ($event['event'] === 'payment.paid') {
// Deduplicate on $event['eventId'], then fulfil $event['externalId'].
}
http_response_code(204);
}
$orderId = $_POST['orderId'] ?? 'order-1001';
$amountMinor = $_POST['amountMinor'] ?? '250000';
$invoice = createInvoice(getenv('VEILNEBULA_API_KEY') ?: '', $orderId, $amountMinor);
header('Location: ' . $invoice['checkoutUrl'], response_code: 303);
// In the webhook route, call:
// handleWebhook(file_get_contents('php://input'), $_SERVER['HTTP_X_NEBULA_SIGNATURE'] ?? '', getenv('VEILNEBULA_WEBHOOK_SECRET') ?: '');Scope to plan for
VeilNebula currently accepts USDT and USDC only. Payouts and refunds are not live yet; fiat and BTC are not supported. Keep those paths disabled in the migrated integration.