NHBCoin
NHBCoin

Guide: Accepting Payments with NHBCoin

This is the real, currently-working integration: your backend creates a checkout invoice with a live API key, redirects the customer to a hosted NHBCoin checkout page, and your webhook fires once the customer's payment is confirmed on-chain. You never touch a private key, sign a transaction, or sponsor gas -- NHBCoin's own infrastructure handles all of that.

Review the merchant tooling docs on GitHub

Step 1: Get a live API key

From a business workspace, go to Developer -> API Keys and generate a key. The secret is shown once -- store it server-side only, never in frontend code.

  • Live keys only for now: sandbox-environment keys are issued but the checkout-invoice endpoint currently rejects them outright (sandbox_not_supported) -- there is no simulated/test-mode payment flow yet.
  • Scopes matter: a key needs the checkout:write, payments:write, invoices:write, or * scope to create invoices -- a key with none of those is rejected with api_scope_denied.

Step 2: Create a checkout invoice from your backend

This call must happen server-side -- it uses your secret API key. Send an Idempotency-Key unique to this order so a network retry can't create a duplicate invoice.

server.mjs
// server.mjs -- this call must happen server-side, it uses your secret key
import express from 'express';

const app = express();
app.use(express.json());

const NHB_API_URL = 'https://portal.nhbcoin.com/api/v1/checkout/invoice';
const NHB_API_KEY = process.env.NHB_API_KEY; // sk_live_...

app.post('/checkout', async (req, res) => {
  const { orderId, amountUsd, customerEmail } = req.body;

  const response = await fetch(NHB_API_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Api-Key': NHB_API_KEY,
      // Unique per order -- a client retry with the same key reuses
      // the same invoice instead of creating a duplicate.
      'Idempotency-Key': orderId
    },
    body: JSON.stringify({
      amountFiat: amountUsd.toFixed(2),
      fiatCurrency: 'USD',
      orderId,
      customerEmail,
      expiresMinutes: 60
    })
  });

  if (!response.ok) {
    const errorBody = await response.json();
    return res.status(response.status).json(errorBody);
  }

  const invoice = await response.json();
  // Send the customer to invoice.payUrl -- that hosted page handles
  // their wallet connection and payment. Nothing else to build here.
  res.json({ payUrl: invoice.payUrl, expiresAt: invoice.expiresAt });
});

app.listen(3001, () => console.log('Merchant server running on port 3001'));

Step 3: Redirect your customer to payUrl

The response includes a hosted payUrl on nhbcoin.com. Send the customer there (redirect or open in a new tab) -- that page handles the customer's wallet connection and payment. A few fields worth knowing about the response:

  • amountNHB is the exact NHB amount the customer will be asked to pay, computed from amountFiat at the current oracle price -- it will not match your fiat amount 1:1.
  • expiresAt reflects expiresMinutes from your request (default 60, 5-43200 allowed) -- the invoice can't be paid after this.
  • webhookConfigured tells you whether this workspace has a webhook URL saved yet (see Step 5) -- if false, you'll need to poll for payment status instead of waiting for a webhook.
  • Only fiatCurrency: "USD" is currently supported -- any other value is rejected with unsupported_currency.

Step 4: The request and response shapes

Every field the API actually accepts and returns -- nothing here is simplified or partial.

invoice-shape.json
// Request body (POST /api/v1/checkout/invoice)
{
  "amountFiat": "100.00",
  "fiatCurrency": "USD",       // only "USD" is currently supported
  "orderId": "ORD-12345",       // your own order identifier
  "memo": "Order #12345",       // optional, defaults to orderId
  "customerEmail": "customer@example.com",  // optional
  "successUrl": "https://you.example/thanks",  // optional
  "cancelUrl": "https://you.example/cart",     // optional
  "expiresMinutes": 60          // optional, 5-43200, default 60
}

// Response (201)
{
  "checkoutSessionId": "cm...",
  "orderId": "ORD-12345",
  "amountFiat": "100.00",
  "fiatCurrency": "USD",
  "amountNHB": "104.31",        // computed from amountFiat at the
                                 // current oracle price -- not 1:1
  "payUrl": "https://portal.nhbcoin.com/r/AbC123",
  "expiresAt": "2026-08-05T13:00:00.000Z",
  "status": "pending",
  "workspaceId": "wksp_...",
  "workspaceName": "Your Business",
  "environment": "live",
  "webhookConfigured": true     // false if no webhook URL is saved
                                 // yet for this workspace (see Step 5)
}

Step 5: Configure your webhook (once per workspace)

Unlike the invoice call, your webhook URL and secret are configured once in Developer -> Webhooks, not per-request -- every completed payment across your workspace fires to that same URL. Verify each delivery: recompute hex(hmac_sha256(secret, "POST|" + webhookPath + "|" + rawRequestBody + "|" + xTimestampHeader)) (where webhookPath is the path of your own webhook URL, rawRequestBody is the exact raw bytes received, and xTimestampHeader is the x-timestamp header's value) and compare it to the x-signature header using a constant-time comparison. Reject anything that doesn't match, and reject stale timestamps to prevent replay.

webhook-handler.js
import { createHmac, timingSafeEqual } from 'node:crypto';

app.post('/webhooks/nhbcoin', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = req.header('x-timestamp');
  const signature = req.header('x-signature');
  const rawBody = req.body; // Buffer -- must be the exact raw bytes

  // Reject anything older than a few minutes to prevent replay.
  const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!timestamp || ageSeconds > 300) {
    return res.status(400).send('stale or missing timestamp');
  }

  // The signed path is YOUR webhook URL's own path, e.g. '/webhooks/nhbcoin'
  // -- not a fixed NHBCoin-side path.
  const canonical = ['POST', req.path, rawBody.toString('utf8'), timestamp].join('|');
  const expected = createHmac(
    'sha256',
    process.env.NHB_WEBHOOK_SECRET // whsec_..., from Developer -> Webhooks
  ).update(canonical).digest('hex');

  const expectedBuf = Buffer.from(expected, 'hex');
  const gotBuf = Buffer.from(signature ?? '', 'hex');
  if (expectedBuf.length !== gotBuf.length || !timingSafeEqual(expectedBuf, gotBuf)) {
    return res.status(401).send('invalid signature');
  }

  const event = JSON.parse(rawBody);
  if (event.event === 'payment.completed') {
    // Fulfill the order tied to event.orderId here.
  }

  res.sendStatus(200);
});

Step 6: Handling rate limits

Invoice creation is capped per API key at 30 requests/minute. A 429 response means you've exceeded that -- back off and retry, don't hammer it. This is a hard per-key cap, not a burst allowance with headroom.

rate-limits.js
const response = await fetch(NHB_API_URL, options);

if (response.status === 429) {
  // 30 requests/minute per API key, hard cap -- back off, don't retry immediately.
  throw new Error('Rate limited creating this invoice; retry shortly.');
}

if (!response.ok) {
  const errorBody = await response.json();
  // errorBody.code is one of: api_key_required, api_key_invalid,
  // invalid_amount, unsupported_currency, api_scope_denied,
  // sandbox_not_supported, idempotency_conflict, oracle_unavailable,
  // workspace_not_found
  throw new Error(`Invoice creation failed: ${errorBody.code}`);
}

return response.json();

Step 7: What the customer sees

The hosted checkout page at payUrl is entirely NHBCoin's -- your frontend's job ends at redirecting the customer there. They need an existing NHBCoin portal account to pay (there is currently no guest/anonymous checkout path).

Step 8: Fulfillment -- trust the webhook, not the browser

Never mark an order fulfilled because the customer's browser redirected back to a success URL -- that's client-controlled and unverifiable. Only fulfill once you've received and signature-verified a payment.completed webhook (or, if no webhook is configured for this workspace, once you've independently confirmed the invoice's status yourself).