What you'll build

A complete Wave mobile money integration for West Africa and Uganda. By the end of this tutorial you will have:

Wave operates in Senegal, Cote d'Ivoire, Burkina Faso, Mali, Guinea, and Uganda. It has a clean, modern API — no OAuth dance, no hash signing. You get one API key, and that is all you need.

Get your API key

  1. Go to wave.com/business and sign up for a Wave business account
  2. In your Wave business dashboard, navigate to API or Developer Settings
  3. Generate an API key — this is the only credential you need

Add these to your .env file:

WAVE_API_KEY=your_wave_api_key
WAVE_BASE_URL=https://api.wave.com/v1
WAVE_CURRENCY=XOF

Wave amounts are always integers in the smallest unit of the currency. For XOF, 1000 means 1000 CFA francs (there are no centimes). For UGX, 5000 means 5000 Ugandan shillings. No decimal conversion needed — just pass the amount as the customer sees it.

Build the payment endpoint

Wave uses a checkout session model. You create a session on your server, get back a URL, and redirect the customer there. Wave handles the entire payment UI.

BlocWeave prompt

Create a Wave mobile money payment integration for an Express app. 1. src/services/wave.service.ts — export: - createCheckoutSession({ amount, currency, orderId, successUrl, errorUrl }): POST to ${WAVE_BASE_URL}/checkout/sessions with headers: Authorization: Bearer WAVE_API_KEY, Content-Type: application/json. Body: { amount (integer), currency (default WAVE_CURRENCY), error_url: errorUrl, success_url: successUrl, payment_method_type: "mobile_money" }. Return the response body which includes id and wave_launch_url. - getCheckoutSession(sessionId): GET ${WAVE_BASE_URL}/checkout/sessions/${sessionId} with Bearer token. Return the response body which includes payment_status. 2. src/controllers/payments.controller.ts — export createWavePayment(req, res, next): - Validate req.body has amount (positive integer) and order_id (string). Optional: currency (default from env). - Call createCheckoutSession with the order details, using your frontend URLs for success_url and error_url - Save the order to your database with status "pending", the Wave session_id, and the amount - Return 201 with { checkoutUrl: response.wave_launch_url, sessionId: response.id, orderId } 3. src/routes/payments.router.ts — POST /wave → createWavePayment. Mount under /payments in index.ts.

// Example response from Wave
{
  "id": "cos-abc123def456",
  "wave_launch_url": "https://pay.wave.com/c/cos-abc123def456",
  "payment_status": "pending",
  "amount": "1000",
  "currency": "XOF",
  "when_created": "2026-06-21T12:00:00Z"
}

The customer opens the wave_launch_url on their phone (or scans a QR code). Wave handles the full payment flow — the customer confirms with their Wave PIN. After payment, Wave redirects to your success_url or error_url.

Handle the webhook / verify payment

Wave can send webhooks when payment status changes. Configure the webhook URL in your Wave business dashboard. Like Orange Money, Wave webhooks do not include an HMAC signature — you must verify by calling the checkout session API.

BlocWeave prompt

Add Wave webhook handling and payment verification. 1. src/services/wave.service.ts — getCheckoutSession is already implemented above and returns the session with payment_status field. The possible values are: "pending", "succeeded", "errored". 2. src/controllers/payments.controller.ts — add: - waveWebhook(req, res, next): - Immediately return 200 { received: true } so Wave does not retry - Extract the checkout session ID from the webhook body (req.body.data.id or req.body.id — check both) - Look up the order in your database by wave_session_id - If order not found, log a warning and return (do not error — Wave retries on non-200) - Call getCheckoutSession(sessionId) to get the authoritative status from Wave - If payment_status === "succeeded": update order status to "paid" and record the payment timestamp - If payment_status === "errored": update order status to "failed" - If payment_status === "pending": do nothing, wait for the next webhook - checkWavePayment(req, res, next): - Extract session_id from req.params - Call getCheckoutSession(session_id) - Return 200 with the payment_status — this lets your frontend poll for status 3. src/routes/payments.router.ts — add POST /wave/webhook → waveWebhook, GET /wave/status/:session_id → checkWavePayment.

// Checkout session after successful payment
{
  "id": "cos-abc123def456",
  "payment_status": "succeeded",
  "amount": "1000",
  "currency": "XOF",
  "when_completed": "2026-06-21T12:05:00Z"
}

No HMAC means verify server-side. Wave webhooks are unsigned, so never trust the webhook body to confirm payment. Always call GET /checkout/sessions/:id to get the authoritative status. This is a deliberate design choice — Wave keeps the API simple and expects you to verify via the session endpoint.

Your frontend can also poll the status endpoint while waiting for the customer to complete payment:

// Frontend polling example
async function pollPaymentStatus(sessionId) {
  const interval = setInterval(async () => {
    const res = await fetch(`/payments/wave/status/${sessionId}`);
    const { payment_status } = await res.json();

    if (payment_status === "succeeded") {
      clearInterval(interval);
      window.location.href = "/order/confirmed";
    } else if (payment_status === "errored") {
      clearInterval(interval);
      window.location.href = "/order/failed";
    }
    // "pending" — keep polling
  }, 3000);
}

Test it

  1. Set your environment variables and start the server:
npm run dev
  1. Create a payment session:
curl -X POST http://localhost:3000/payments/wave \
  -H "Content-Type: application/json" \
  -d '{"order_id": "ORD-TEST-001", "amount": 1000}'
  1. Open the returned checkoutUrl on a phone with the Wave app installed (or in a mobile browser during testing)

  2. Complete the payment using your Wave test account

  3. After payment, Wave redirects to your success_url and sends a webhook to your server

  4. Check your database — the order should show status paid

  5. Test the polling endpoint:

curl http://localhost:3000/payments/wave/status/cos-abc123def456
# Returns: { "payment_status": "succeeded" }
  1. To receive webhooks locally, use ngrok:
ngrok http 3000
# Set the ngrok URL as your webhook URL in the Wave dashboard

Project structure after this tutorial

src/
  controllers/
    payments.controller.ts
  routes/
    payments.router.ts
  services/
    wave.service.ts