Skip to content
Test money only. BitMomo currently runs on test networks. Payments use testnet coins and no real money moves. Live keys are not available yet.

Build a merchant server

Networks fail, servers restart and webhooks arrive twice. This pattern keeps your orders correct anyway. The SDK ships a complete working version in node_modules/@bitmomo/merchant-sdk/guides/:

  • reference-merchant.sql: the tables, for your own database.
  • reference-merchant.cjs: createCheckout, acceptWebhook and processOneEvent, built on a pg connection pool.
Table Purpose
Orders Your orders, with the price and the customer who owns them. The only source of prices.
Payment attempts One row per order: the idempotency key (saved before calling BitMomo) and, once known, the BitMomo payment ID.
Event inbox Every verified webhook, unique by delivery_id. Written before you reply 200.
Fulfilment jobs Work to do once an order is paid, created in the same transaction that marks the order paid.
async function createCheckout(orderId, authenticatedCustomerId) {
// Load the order for the signed-in customer. Never trust a price from the browser.
const order = await db.oneOrNone(
"SELECT id, amount_ghs::text FROM orders WHERE id = $1 AND customer_id = $2 AND status = 'awaiting_payment'",
[orderId, authenticatedCustomerId]);
if (!order) throw new Error('Order not found');
// Save the intent BEFORE calling BitMomo, so a timeout can be retried safely.
const attempt = await db.one(
`INSERT INTO payment_attempts (order_id, intent_key, amount_ghs) VALUES ($1, $2, $3)
ON CONFLICT (order_id) DO UPDATE SET order_id = EXCLUDED.order_id
RETURNING intent_key, amount_ghs::text`,
[order.id, randomUUID(), order.amount_ghs]);
const payment = await bitmomo.createPayment({
amount: attempt.amount_ghs, crypto_type: 'USDT_TRC20', fiat_currency: 'GHS',
order_id: order.id, idempotency_key: attempt.intent_key,
});
await db.none('UPDATE payment_attempts SET payment_id = $2 WHERE order_id = $1', [order.id, payment.id]);
return payment;
}

If the response is lost, calling createCheckout again reuses the saved intent_key, and BitMomo returns the same payment.

async function acceptWebhook(rawBody, headers) {
const verified = verifyWebhookEvent(rawBody, headers['x-webhook-signature'], {
secrets, timestamp: headers['x-webhook-timestamp'], keyVersion: headers['x-webhook-key-version'],
});
if (!verified.valid) throw new Error(`Webhook rejected: ${verified.reason}`);
const body = JSON.parse(rawBody.toString('utf8'));
// Unique delivery_id: duplicates are ignored, not processed twice.
await db.none(
'INSERT INTO event_inbox (delivery_id, payment_id, event, body) VALUES ($1, $2, $3, $4) ON CONFLICT (delivery_id) DO NOTHING',
[verified.envelope.deliveryId, body.payment_id, verified.envelope.event, body]);
// Only now reply 200. If anything above throws, reply with an error so BitMomo retries.
}

In a background worker, take one unprocessed inbox row at a time inside a transaction:

  1. Fetch the payment from BitMomo with getPayment. The webhook only tells you which payment to check.
  2. Find the order through your payment attempts table.
  3. Check everything: the merchant, environment, status === 'completed', the order is still awaiting payment, order_terms.order_id matches, and economics.recognized_gross_ghs covers the order amount exactly.
  4. Mark the order paid and insert one fulfilment job, in the same transaction.
  5. Mark the inbox row processed and commit.

If any check fails, roll back and leave the order for manual review. Payments that do not match an order in your system (for example ones created from the BitMomo POS) can simply be marked processed.

Your fulfilment worker reads jobs and performs the side effects (shipping, emails, account upgrades). A database transaction cannot make an external action happen exactly once, so pass the job ID as an idempotency key to whatever you call downstream.

  • Express: mount express.raw({ type: 'application/json' }) on the webhook route before express.json().
  • Next.js App Router: read Buffer.from(await request.arrayBuffer()) in a route handler, and import the SDK only in server code.
  • Return pages: a customer returning from checkout does not prove payment. Show the order’s current state; only your worker marks orders paid.

The reference handles one merchant account. If you are a platform serving several BitMomo merchants, add the merchant to every table and check it on every step.