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,acceptWebhookandprocessOneEvent, built on apgconnection pool.
The four tables
Section titled “The four tables”| 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. |
1. Create the checkout
Section titled “1. Create the checkout”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.
2. Accept webhooks durably
Section titled “2. Accept webhooks durably”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.}3. Process events in a worker
Section titled “3. Process events in a worker”In a background worker, take one unprocessed inbox row at a time inside a transaction:
- Fetch the payment from BitMomo with
getPayment. The webhook only tells you which payment to check. - Find the order through your payment attempts table.
- Check everything: the merchant,
environment,status === 'completed', the order is still awaiting payment,order_terms.order_idmatches, andeconomics.recognized_gross_ghscovers the order amount exactly. - Mark the order paid and insert one fulfilment job, in the same transaction.
- 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.
4. Fulfil once
Section titled “4. Fulfil once”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.
Framework notes
Section titled “Framework notes”- Express: mount
express.raw({ type: 'application/json' })on the webhook route beforeexpress.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.
Multiple merchants
Section titled “Multiple merchants”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.