Skip to content
Migration guide

Replace Coinbase Commerce after its March 31, 2026 closure.

The old charge model maps cleanly to a VeilNebula invoice and hosted checkout. Keep your order IDs and fulfillment rules; replace the provider API calls and webhook verifier.

Concept mapping

Coinbase CommerceVeilNebulaMigration note
POST /chargesPOST /v1/paymentsCreate one VeilNebula invoice per order. Store the returned id alongside your order.
X-CC-Api-Keyx-api-key + x-ncp-timestamp + x-ncp-signatureProject API key. POST requests also carry a hex HMAC-SHA256 signature, keyed with the API key, over timestamp.METHOD.path.rawBody.
local_price { amount, currency }amountMinor + currencyCodeamountMinor is a string of digits in minor units: 25.00 USD is "2500".
metadata (order reference)externalIdKeep your order id here; look it up with GET /v1/payments/by-order/{orderId}.
redirect_url / cancel_urlsuccessUrl / cancelUrlAbsolute http(s) URLs for the customer redirect.
hosted_urlcheckoutUrlRedirect the customer to the URL returned by POST /v1/payments.
pricingamountMinor, totalFeeMinor, netAmountMinorCoinbase lists per-currency prices; VeilNebula returns the invoice amount plus explicit fee and net fields.
X-CC-Webhook-Signaturex-nebula-signatureHMAC-SHA256 over t + "." + raw body (not the raw body alone), keyed with the endpoint secret; header format t=...,v1=....
charge:pendingpayment.confirmingDeposit seen on chain, waiting for confirmations.
charge:confirmedpayment.paidFulfil after signature verification; deduplicate on eventId.
charge:failedpayment.expired / payment.partially_paidExpired without full payment, or underpaid and still open.

Migration steps

  1. Set up a VeilNebula account and project. In the dashboard at https://app.veilnebu.la, start in sandbox and create a live project after the full flow is verified.
  2. Map charges to invoices. Keep your existing order identifier in externalId; save the returned invoice id and checkoutUrl.
  3. Update code. Replace charge creation with signed POST /v1/payments. Send x-api-key, request-signature headers and a stable idempotency-key.
  4. Update webhooks. Register your URL as a project webhook endpoint and store its signing secret as VEILNEBULA_WEBHOOK_SECRET. Receive the raw body, verify x-nebula-signature, deduplicate on eventId, and map payment.paid to fulfillment.

Node.js example

Use express.raw for the webhook route: the HMAC covers the exact bytes received, so parse JSON only after 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 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('/checkout', express.json(), async (req, res) => {
  const orderId = String(req.body.orderId);
  const path = '/v1/payments';
  const body = JSON.stringify({
    // Coinbase local_price { amount: '25.00', currency: 'USD' } becomes minor units.
    amountMinor: String(Math.round(Number(req.body.totalUsd) * 100)),
    currencyCode: 'USD',
    settlementAssetCode: 'USDT',
    externalId: orderId,
    description: req.body.description,
    successUrl: 'https://merchant.example/paid',
    cancelUrl: 'https://merchant.example/cancelled',
  });
  const timestamp = new Date().toISOString();
  const signature = createHmac('sha256', apiKey)
    .update([timestamp, 'POST', path, body].join('.'))
    .digest('hex');
  const response = await fetch('https://api.veilnebu.la' + path, {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      'x-api-key': apiKey,
      'x-ncp-timestamp': timestamp,
      'x-ncp-signature': signature,
      'idempotency-key': 'coinbase-migration:' + 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') {
    // Replaces charge:confirmed. Deduplicate on event.eventId.
  }
  return res.sendStatus(204);
});

Current product boundaries

VeilNebula accepts USDT and USDC. Payouts and refunds are not live yet, and fiat and BTC are not available. Preserve any legacy flows that depend on those capabilities until an equivalent is live.

Migrate from Coinbase Commerce · VeilNebula