./docs

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.

./install

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_..." }
./auth

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/balances
./invoices

Your 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

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.com does not cover https://www.shop.com or http://shop.com. No paths, no wildcards; http:// only for localhost.
  • Requests whose Origin header is missing or not on the list are rejected with 401.
  • successUrl / cancelUrl sent 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-key are not restricted: any absolute http(s) redirect URL is accepted.
  • amount is a decimal string in currencyCode: 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"
}
./statuses

Payment statuses

Every invoice reports one of these. The next action column is what the merchant dashboard shows an operator for that state.

StatusLabelNext action
newNewShare checkout link
pendingPendingWaiting for payment
confirmingConfirmingWaiting for confirmations
paidPaidSettlement ready
partially_paidPartialAsk customer to complete payment
overpaidOverpaidReview credited extra amount
expiredExpiredCreate a new invoice
failedFailedOpen details
cancelledCancelledNo action required
refundedRefundedNo action required
./idempotency

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

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 test

Just 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.

./payout-batches

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 batch
./subscriptions

Recurring 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}/cancel
./networks

Networks + 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

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-bot

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
Docs · veilnebu.la