@mutwave/express
v0.4.0
Published
Mutwave server-side SDK for Node.js/Express backends — call Mutwave's API with your app's secret key (create and manage customer accounts, move money, sell airtime/data/bills, reply to support tickets) and verify incoming Mutwave webhooks.
Readme
@mutwave/express
Mutwave's server-side SDK for Node.js/Express backends. Where @mutwave/js /
@mutwave/nextjs / @mutwave/react-native let your customers talk to Mutwave
from a browser or mobile app, @mutwave/express is for your own backend
talking to Mutwave directly — with your app's secret key, no cookies, no
logged-in session, no origin allowlist.
Use it to:
- Charge a customer through Flutterwave, Paystack, OPay, Monnify, Bachs, or Squad — one method call, any provider, no separate integration per provider.
- Create and look up customer accounts from your own signup flow.
- Move money, sell airtime/data/cable/electricity, and read transaction history on a customer's behalf.
- Reply to (or resolve, or hand over) a support ticket as your app, when "developer-first support" is turned on for your app.
- Verify that a webhook claiming to be from Mutwave actually is.
If you're building a browser or mobile frontend for your own end users
instead, you want @mutwave/nextjs, @mutwave/js,
or @mutwave/react-native — those authenticate a
specific logged-in customer with a publishable key; this package
authenticates your backend with your secret key and can act on any of
your app's customers.
Install
npm install @mutwave/expressexpress is an optional peer dependency — only needed if you use
mutwaveWebhookMiddleware. Everything else in this package works with any
Node HTTP framework, or none at all. Requires Node 18+ (uses the built-in
fetch).
Quick start
import { mutwave } from "@mutwave/express";
const client = mutwave.client({
secretKey: process.env.MUTWAVE_SECRET_KEY!, // sk_live_... or sk_test_...
});
const { account, user } = await client.account.create({
firstName: "Ada",
lastName: "Obi",
email: "[email protected]",
phoneNumber: "08012345678",
bvn: "22123456789",
password: "a-strong-password",
// passcode is optional — leave it out and the account starts on a known
// placeholder that blocks money movement until the customer sets a real
// one themselves (client.account.requestPasscodeChange()/
// confirmPasscodeChange(), called from a customer-facing SDK — only the
// account owner can change their own passcode).
});
// Doesn't hand back a usable session — Mutwave emails the customer a
// one-time code. Relay their reply from your own UI to get a real JWT:
const { token } = await client.account.verifyOtp("[email protected]", otp);⚠️ Secret key — server only
secretKey is your app's full-access key (Settings → API Keys → Live/Test
Secret key). It can move money and read every customer's data. Never
ship it to a browser, a mobile app, or any client you don't control —
mutwave.client({ secretKey }) should only ever run on a server you own. If
you need customer-facing code to talk to Mutwave, use the publishable key
with @mutwave/js/@mutwave/nextjs/@mutwave/react-native instead.
MutwaveClient keeps secretKey in a real private class field (#secretKey),
not just a TypeScript private — it will never show up in
Object.keys(client), console.log(client), or JSON.stringify(client), so
an accidental debug log of the client object can't leak it. Handling the raw
string itself (env vars, logs, error messages you write yourself) is still
on you — never log process.env.MUTWAVE_SECRET_KEY.
Whichever key you pass — live or test — decides every call's mode
automatically. There's no separate testMode flag to set.
Modules
client.payment
One integration for every provider — swap provider and the request/response
shape, and the webhook confirmation, stay identical.
const { checkoutUrl, reference } = await client.payment.charge("flutterwave", {
amount: 5000,
firstName: "Ada",
lastName: "Obi",
email: "[email protected]",
phoneNumber: "08012345678", // optional
reference: "order-1029", // optional — your own order/idempotency reference
description: "Order #1029", // optional
redirectUrl: "https://yourapp.com/thank-you", // optional — defaults to your app's webhook callback URL
});
// Redirect the customer to complete payment:
res.redirect(checkoutUrl);provider is one of "flutterwave" | "paystack" | "opay" | "monnify" | "bachs" | "squad".
No pre-existing Mutwave Account/userId is needed — this charges anyone, and
the money lands directly in your app's own withdrawable balance, never a
Mutwave wallet.
This call only returns a checkout link — it is not proof of payment.
Confirm the charge server-side by listening for the transaction.success
event on your webhook (see Webhooks below); event.data.reference
matches the reference this call returned.
client.account
// Create a customer (User + Mutwave Account) under your app. passcode is
// optional — see "Quick start" above.
const { user, account } = await client.account.create({
firstName, lastName, email, phoneNumber, bvn, password,
});
// No usable session yet — Mutwave emailed a one-time code. Relay it from
// your own UI:
const { token, requiresPasswordSetup } = await client.account.verifyOtp(email, otp);
// `token` is a 7-day JWT for this customer — hand it to your own
// frontend/mobile app if it needs to call Mutwave directly afterward,
// without asking them to log in again. Every subsequent login re-sends a
// code too (not just this first one) — verifyOtp() is what redeems those
// as well.
const { user, account } = await client.account.getByEmail("[email protected]");
const { balance } = await client.account.getBalance(account._id);
await client.account.changePasscode({ email, currentPasscode, newPasscode });
// A direct internal-ledger debit — requires the customer's own JWT + passcode,
// not just your secret key, since it's moving one specific customer's money.
await client.account.debit(token, { passcode: "1234", amount: 500 });
// Relay a message into a customer's (unticketed) support chat log.
await client.account.sendChatMessage(account._id, { message: "...", senderName: "Support Bot" });
// Set a customer's own push-notification-sound override for one event —
// the same override you can otherwise only set from Settings → Customers
// on the dashboard, done here server-side (e.g. for your own "pick a
// sound" UI). `sound` is a data: URI or hosted URL; build it however you
// like, e.g. from a file your own backend already has:
const sound = "data:audio/mpeg;base64," + fs.readFileSync(path).toString("base64");
await client.account.setSound(account._id, "transactionSuccess", sound);
await client.account.clearSound(account._id, "transactionSuccess"); // fall back to the app-wide default againclient.transaction
const all = await client.transaction.list(user._id);
const { transactions, hasMore } = await client.transaction.list(user._id, { skip: 0, count: 20 });
await client.transaction.transferWithinApp({ accountId, accountNumber, amount, passcode });
await client.transaction.transferToBank({ accountId, accountNumber, bankCode, amount, passcode, narration });
const banks = await client.transaction.listBanks();
const { account_name } = await client.transaction.resolveExternalAccount({ accountNumber, bankCode });
const { data } = await client.transaction.resolveInternalAccount(accountNumber); // one of your own app's accountsclient.vtu
const billers = await client.vtu.getBillers("CABLE");
const items = await client.vtu.getItems({ billerCode, category: "CABLE" });
await client.vtu.buyAirtime({ accountId, billerCode, itemCode, customer: "08012345678", amount: 500 });
await client.vtu.buyData({ accountId, billerCode, itemCode, customer: "08012345678" });
await client.vtu.buyCable({ accountId, billerCode, itemCode, customer: "1234567890" });
await client.vtu.buyElectricity({ accountId, billerCode, itemCode, customer: "01234567890" });client.ticket
Only relevant if your app has developer-first support turned on
(Settings → Customer Support). While a ticket is in "developer" status,
Mutwave's AI and staff can't see it at all — your backend owns the
conversation until you resolve it or hand it off.
await client.ticket.reply(ticketId, "Thanks for reaching out — looking into it now.");
await client.ticket.handOver(ticketId); // → a Mutwave support agent takes over
await client.ticket.resolve(ticketId); // → you close it out yourselfclient.app
const { app, features } = await client.app.getPublicConfig();client.auth
const { keyType } = await client.auth.whoAmI(); // "production" | "testing"Cheap way to confirm your secret key is valid — useful in a startup or
health-check route; throws MutwaveApiError if the key is wrong or revoked.
Webhooks
Mutwave signs every webhook with HMAC-SHA256 over the raw JSON body, using
your app's webhook secret (Settings → Webhooks), sent as the
x-mutwave-signature header. Always verify this before trusting a webhook
payload — without it, anyone who knows your webhook URL could POST fake
events (e.g. a fake transaction.success).
Easiest: mutwaveWebhookMiddleware
import express from "express";
import { mutwave, mutwaveWebhookMiddleware } from "@mutwave/express";
const client = mutwave.client({ secretKey: process.env.MUTWAVE_SECRET_KEY! });
const app = express();
// Mount this BEFORE any express.json() on the same path — the middleware
// needs the untouched raw request bytes to verify the signature against.
app.post(
"/webhooks/mutwave",
mutwaveWebhookMiddleware(process.env.MUTWAVE_WEBHOOK_SECRET!),
(req, res) => {
const event = req.mutwaveEvent!; // typed, verified — safe to trust
switch (event.event) {
case "transaction.success":
// credit the user, update your own records
break;
case "ticket.customerMessage":
// a customer replied on a ticket your backend owns
client.ticket.reply(event.data.ticketId as string, "On it!");
break;
}
res.sendStatus(200);
}
);If your app already has a global express.json() mounted, this route needs
to be exempted from it (register this route before the global parser, or
scope the global parser to only the paths that need it) — express.json()
consumes the raw body stream, and by the time it's done there's nothing
left for the signature check to verify against.
Manual: constructEvent / isValidSignature
For any other framework (Fastify, raw http, a serverless function), get
the raw request body as a Buffer or string — not JSON.stringify()
of an already-parsed object, which can differ in key order/whitespace from
what was actually signed — and verify it yourself:
import { constructEvent, MutwaveWebhookSignatureError } from "@mutwave/express";
// rawBody must be the exact bytes Mutwave sent, captured before JSON parsing.
try {
const event = constructEvent(rawBody, req.headers["x-mutwave-signature"], webhookSecret);
// event.event, event.data, event.timestamp
} catch (err) {
if (err instanceof MutwaveWebhookSignatureError) {
return res.status(401).send("Invalid signature");
}
throw err;
}
// Or just a boolean, if you don't need the parsed event:
import { isValidSignature } from "@mutwave/express";
isValidSignature(rawBody, req.headers["x-mutwave-signature"], webhookSecret); // booleanEvent types
event.event is typed as MutwaveWebhookEventType, covering every event
Mutwave can send: transaction.success, transaction.failed,
account.created, account.suspended, account.reactivated,
ticket.created, ticket.assigned, ticket.escalated, ticket.resolved,
ticket.customerMessage, ticket.handedBack, withdrawal.simulated,
loan.applied, loan.approved, loan.rejected, loan.disbursed,
loan.repaymentSucceeded, loan.repaymentFailed, loan.overdue,
loan.defaulted, loan.repaid, card.created, card.transactionApproved,
card.transactionDeclined, card.frozen, card.unfrozen,
card.terminated, card.physicalOrdered, card.physicalShipped. Turn on
the specific events you want under Settings → Webhooks — Mutwave only sends
what you've subscribed to.
Errors
Every failed API call throws MutwaveApiError (.message, .status).
Signature verification failures throw MutwaveWebhookSignatureError
separately, so you can tell "Mutwave rejected the request" apart from
"this webhook wasn't actually from Mutwave":
import { MutwaveApiError } from "@mutwave/express";
try {
await client.account.create({ ... });
} catch (err) {
if (err instanceof MutwaveApiError) {
console.error(`Mutwave API error (${err.status}): ${err.message}`);
}
throw err;
}Example
See example/ for a complete, runnable Express server: create
a customer, list banks, and verify an incoming webhook end to end.
npm run build # from the repo root — builds dist/ so the example can require it
cd example && npm install
MUTWAVE_SECRET_KEY=sk_test_... MUTWAVE_WEBHOOK_SECRET=whsec_... npm start