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 GitHubStep 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 withapi_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 -- 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:
amountNHBis the exact NHB amount the customer will be asked to pay, computed fromamountFiatat the current oracle price -- it will not match your fiat amount 1:1.expiresAtreflectsexpiresMinutesfrom your request (default 60, 5-43200 allowed) -- the invoice can't be paid after this.webhookConfiguredtells 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 withunsupported_currency.
Step 4: The request and response shapes
Every field the API actually accepts and returns -- nothing here is simplified or partial.
// 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.
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.
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).