Developer reference
Read the source, run the sandbox, ship to your merchants. Everything here matches the OpenAPI spec — if anything diverges, the API is the source of truth.
Your first charge in 30 seconds
Nothing to host and nothing to install. Create a project, copy its key, and post an invoice — the response carries the checkout URL you send your customer to.
$ curl -X POST https://api.veilnebu.la/v1/payments \
-H "x-api-key: ncp_test_..." \
-H "content-type: application/json" \
-H "idempotency-key: $(uuidgen)" \
-d '{"amountMinor":"2500","currencyCode":"USD","settlementAssetCode":"USDT"}'
{ "id": "pay_...", "status": "PENDING",
"checkoutUrl": "https://veilnebu.la/checkout/pay_..." }Authentication
Server-to-server calls authenticate with x-api-key. Project keys are scoped to a single merchant project. Operator endpoints require a separate ADMIN_API_KEY.
curl -H "x-api-key: ncp_test_..." \
https://api.veilnebu.la/v1/balancesYour first invoice
POST /v1/payments
Idempotency-Key: order-1234
x-api-key: ncp_test_...
{
"amount": "100.00",
"currencyCode": "USD",
"settlementAssetCode": "USDT",
"successUrl": "https://you.example/success",
"cancelUrl": "https://you.example/cancel"
}Browser checkout + allowed origins
The embed widget and SDKs create invoices straight from the shopper's browser with a publishable key (pk_test_… / pk_live_…) sent as x-publishable-key to POST /v1/public/payments. Issue one in Dashboard → API keys → Publishable keys and list every origin your storefront runs on.
- An origin is
scheme://host[:port]and is matched exactly:https://shop.comdoes not coverhttps://www.shop.comorhttp://shop.com. No paths, no wildcards;http://only for localhost. - Requests whose
Originheader is missing or not on the list are rejected with 401. successUrl/cancelUrlsent with a publishable key must point to one of the allowed origins, otherwise 400. A key without allowed origins cannot use redirect URLs at all.- Server-side calls with your secret
x-api-keyare not restricted: any absolute http(s) redirect URL is accepted. amountis a decimal string incurrencyCode: up to 2 fraction digits for fiat, up to the asset's own decimals for crypto (USDT 6, BTC 8, ETH 18). More digits than that is a 400.
POST /v1/public/payments
Origin: https://shop.example # must be an allowed origin
x-publishable-key: pk_test_...
{
"amount": "100.00",
"currencyCode": "USD",
"settlementAssetCode": "USDT",
"successUrl": "https://shop.example/thanks", # same origin list
"cancelUrl": "https://shop.example/cart"
}Payment statuses
Every invoice reports one of these. The next action column is what the merchant dashboard shows an operator for that state.
| Status | Label | Next action |
|---|---|---|
new | New | Share checkout link |
pending | Pending | Waiting for payment |
confirming | Confirming | Waiting for confirmations |
paid | Paid | Settlement ready |
partially_paid | Partial | Ask customer to complete payment |
overpaid | Overpaid | Review credited extra amount |
expired | Expired | Create a new invoice |
failed | Failed | Open details |
cancelled | Cancelled | No action required |
refunded | Refunded | No action required |
Idempotency keys
Send an Idempotency-Key header on every write. A repeat of the same key returns the original resource instead of creating a second one, so a retried request after a timeout cannot double-charge a customer or issue a duplicate payout. Use a value derived from your own order id, not a random one, or a retry will generate a fresh key and defeat the guard.
POST /v1/payments Idempotency-Key: order-1234 # ← your order id, stable across retries x-api-key: ncp_test_...
Webhooks (v1 HMAC + v2 Ed25519)
Every event carries x-nebula-signature: t=<unix>,v1=<hex>,v2=<b64>?. The pre-rebrand x-novacrypta-signature header is still sent alongside with the same value. Verify within 300 seconds of t.
import { verifyWebhook } from '@nebula/sdk';
if (!verifyWebhook(req.headers['x-nebula-signature'], rawBody, {
secret: process.env.NEBULA_WEBHOOK_SECRET,
})) return res.status(401).end();Testing webhooks locally
Webhook URLs must be reachable from the public internet: localhost, 127.0.0.1 and private ranges (10.*, 192.168.*, …) are rejected with url must target a public internet host — in sandbox too, because the delivery worker must never be pointed at internal services. To receive events on your machine, put a public HTTPS tunnel in front of your local server and register the tunnel URL:
# 1. run your receiver locally, e.g. on :3000
# 2. expose it (pick one)
cloudflared tunnel --url http://localhost:3000 # → https://<random>.trycloudflare.com
ngrok http 3000 # → https://<random>.ngrok-free.app
# 3. register the public URL (sandbox key)
POST /v1/webhooks/endpoints
x-api-key: ncp_test_...
{ "url": "https://<random>.trycloudflare.com/nebula-webhook",
"events": ["payment.paid"] }
# 4. fire a test delivery from Dashboard → Webhooks → Send testJust want to see the payload? Register a public request inspector URL instead of a tunnel. Verify the signature exactly as below — tunnels forward the raw body unchanged.
Verify in Node.js (no SDK)
import crypto from 'node:crypto';
function verify(header, rawBody, secret) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
if (!parts.t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(parts.t + '.' + rawBody)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Verify in Python
import hmac, hashlib, time
def verify(header: str, raw_body: bytes, secret: str) -> bool:
parts = dict(p.split('=') for p in header.split(','))
if abs(time.time() - int(parts['t'])) > 300:
return False
expected = hmac.new(
secret.encode(),
(parts['t'] + '.' + raw_body.decode()).encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, parts['v1'])Verify in PHP
function verify_webhook(string $header, string $body, string $secret): bool {
$parts = [];
foreach (explode(',', $header) as $p) { [$k, $v] = explode('=', $p); $parts[$k] = $v; }
if (abs(time() - intval($parts['t'])) > 300) return false;
$expected = hash_hmac('sha256', $parts['t'].'.'.$body, $secret);
return hash_equals($expected, $parts['v1']);
}Event types
payment.pending— invoice created, awaiting deposit.payment.confirming— tx seen, awaiting confirmations.payment.paid— confirmed; merchant credited.payment.partially_paid— underpaid; partial credit.payment.overpaid— overpaid; full credit.payment.expired— invoice expired before payment.payment.risk_flagged— advisory screening hit on a deposit already credited.payout.completed— outbound transfer broadcast.refund.completed— refund settled to buyer.static_wallet.topup.completed— standing address received deposit.subscription.invoice_created— new cycle invoice minted.internal_transfer.sent/.received— off-chain merchant ↔ merchant move.
Retry policy
We retry non-2xx responses up to 5 times over 24 hours with exponential backoff (1s, 30s, 5m, 30m, 4h). Failed deliveries move to the dead-letter queue (visible in the dashboard) and can be replayed manually.
Mass payouts (dual-approval)
Send one batch of outbound transfers to many destinations. A batch is created with your API key, then needs two distinct operators to approve it — the creator cannot approve their own batch, and every approval requires a WebAuthn step-up in the dashboard. Items execute as regular payouts, each with its own idempotency key (batch:<batchId>:<itemId>), so a retry cannot pay twice.
POST /v1/payout-batches
x-api-key: ncp_live_...
Idempotency-Key: payroll-2026-10
{
"assetCode": "USDT",
"networkCode": "TRON",
"externalId": "payroll-2026-10",
"items": [
{ "destinationAddress": "T...", "amountMinor": "15000000", "externalRef": "emp-001" },
{ "destinationAddress": "T...", "amountMinor": "15000000", "externalRef": "emp-002" }
]
}
POST /v1/payout-batches/{batchId}/approve # one call per operator, after WebAuthn
POST /v1/payout-batches/{batchId}/process # executes a fully approved batchRecurring billing
Create a subscription for a customerRef and the billing loop mints a fresh invoice every cycle, announcing it with asubscription.invoice_created webhook. A subscription that fails billing repeatedly is paused automatically; pause, resume and cancel are explicit lifecycle calls.
POST /v1/subscriptions
x-api-key: ncp_live_...
{
"customerRef": "user-42",
"assetCode": "USDT",
"networkCode": "TRON",
"amountMinor": "9000000",
"interval": "MONTH", // DAY | WEEK | MONTH | YEAR
"intervalCount": 1
}
POST /v1/subscriptions/{id}/pause
POST /v1/subscriptions/{id}/resume
POST /v1/subscriptions/{id}/cancelNetworks + assets
The catalogue a deployment actually sells is a public read: GET /v1/services. It answers mode (live or sandbox) and one entry per payable asset and network, with the confirmations it waits for and, where configured, per-invoice limits and commission. Build your method picker from this response rather than a hard-coded list: an asset the operator switches off disappears from it.
GET /v1/services
{
"mode": "live",
"paymentMethods": [
{ "assetCode": "USDT", "networkCode": "TRON", "displayName": "USDT TRC20", "requiredConfirmations": 20 },
{ "assetCode": "USDT", "networkCode": "ETH", "displayName": "USDT ERC20", "requiredConfirmations": 12 }
],
"api": { "webhookSignatureHeaders": ["x-nebula-timestamp", "x-nebula-signature", "..."] }
}Risk screening
Screening runs on blockchain addresses, never on identities, at two points. Payouts and refunds: the destination is screened before the transfer is created, and a sanctions match or high-risk score rejects the request — in a batch, item by item. Incoming payments: advisory. The deposit is credited first and screened afterwards; a hit sends payment.risk_flagged with riskScore and reasons, and the invoice stays paid — nothing is held or reversed for you, so decide in your fulfilment code. What the lists and providers are is set out in the AML and sanctions policy.
Telegram merchant bot
A chat front-end for one project, run by the operator of this deployment — they publish its handle. Link it with the project’s secret key; the bot deletes that message first and keeps the key encrypted. It can do exactly what the key can, nothing more. Payment DMs (payment.paid, payment.overpaid, payout.completed, refund.completed) reach only chats linked with a live key of that project: revoking the key cuts every chat it linked. Approving a payout is never done from Telegram — the button opens the dashboard, where the WebAuthn step-up happens.
/link <API_KEY> connect this chat to a project /balance /invoices /payouts /refunds /invoice 100 USD USDT create an invoice, reply with its checkout URL /notify on|off payment DMs for this chat /support <category> <message> open a ticket /unlink disconnect and erase the stored key