Back to blog

FortPesa API in Node.js: STK Push, Signed Webhooks, and Reliable Payment Confirmation

A zero-to-production walkthrough for beginners: scaffold a Node.js project, create an M-Pesa STK Push, verify FortPesa's signed webhooks, and confirm payments before fulfilling orders — every command, file, and expected output included.

FortPesa API in Node.js: STK Push, Signed Webhooks, and Reliable Payment Confirmation

In an earlier tutorial, we initiated an M-Pesa STK Push straight from a React component — great for a demo, but production payment flows follow a stricter rule: your API key never leaves the server. This guide builds the other half, starting from an empty folder. If you have never integrated a payment API before, every command, every file, and every expected output is written out below. By the end you will have a small Node.js service that creates an M-Pesa payment, receives FortPesa's signed webhooks, and confirms the transaction before anything ships to the customer.

Keep the API reference open in a second tab while you build — everything we touch here is documented there.

What you are building

The finished service handles four jobs:

  1. Your server calls the FortPesa API to trigger an STK Push — the M-Pesa prompt on the customer's Safaricom line
  2. The customer enters their PIN; FortPesa records the outcome
  3. FortPesa calls your server back with a signed webhook
  4. Your server re-checks the transaction through the API, and only then fulfils the order

A customer confirming a mobile payment on a smartphone

Two conventions matter here: amounts are whole KES integers — no cents, ever — and the API rate-limits at 120 requests per minute, answering with a 429 when you cross the line. Cache reads like balance checks instead of hammering them.

Step 0 — What you need before starting

  • A computer with Node.js 18 or newer installed (check with node --version). Node 18 ships a built-in fetch, so no HTTP library is needed
  • A terminal — Command Prompt, PowerShell, or iTerm all work; the commands below use the standard $ prompt
  • A FortPesa merchant account at fortpesa.co.ke. Three things come out of the dashboard:
    • An API key: open Settings → API keys and create one. It looks like pb_live_... and is shown once at creation — copy it immediately, only a short prefix is stored afterwards
    • Your channel_id: the payment channel the STK Push is billed against
    • A webhook secret (whsec_...) from Settings → Webhooks, used later to verify callbacks

Keep the key on your server only. As the docs warn, anything shipped to a browser can be extracted and used against your account.

Step 1 — Create the project

Open a terminal and run:

mkdir fortpesa-checkout
cd fortpesa-checkout
npm init -y
npm install express dotenv

What each line does:

  • npm init -y creates a package.json (the -y accepts all defaults so it runs non-interactively)
  • express is the web framework — it listens for HTTP requests so both your checkout page and FortPesa's webhooks can reach your code
  • dotenv loads the .env file in Step 2, so secrets stay out of the code itself

Your project folder now looks like this:

fortpesa-checkout/
├── node_modules/     created by npm — never edit or commit this
├── package.json
└── package-lock.json

Step 2 — Store your keys in a .env file

Secrets do not belong in code. Create a file named .env in the project root (same folder as package.json) with your real values:

FORTPESA_API_KEY=pb_live_YOUR_KEY_HERE
FORTPESA_CHANNEL_ID=42
FORTPESA_WEBHOOK_SECRET=whsec_YOUR_SECRET_HERE
PORT=3000

Use your own channel_id from the dashboard — 42 is just an example. Next create a .gitignore file so these secrets never reach Git:

node_modules
.env

Checkpoint — prove the key works before writing any server code. Run this, pasting your key into the header:

curl https://fortpesa.co.ke/api/v1/wallet/balance \
  -H "Authorization: Bearer pb_live_YOUR_KEY_HERE"

A 200 response reporting your prepaid fee wallet balance means the key is live. The wallet endpoint works with any valid key, which makes it a quick smoke test.

Step 3 — Build the checkout endpoint

Create a folder called src, and inside it a file called index.js — the whole server so far:

mkdir src

Then fill src/index.js with:

// src/index.js — a Node.js checkout service for the FortPesa API
require("dotenv").config();
const express = require("express");

const app = express();
app.use(express.json());

app.post("/api/checkout", async (req, res) => {
  const { amount, phone, reference } = req.body;

  const response = await fetch("https://fortpesa.co.ke/api/v1/payments/stk", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.FORTPESA_API_KEY}`,
      "Content-Type": "application/json",
      // Recommended in the docs: prevents double-prompting on client retries
      "Idempotency-Key": reference,
    },
    body: JSON.stringify({
      amount: Math.round(amount),   // whole KES, no cents
      phone,                        // "0712345678" — normalized for you
      account_reference: reference, // e.g. "INV-2041"
      channel_id: Number(process.env.FORTPESA_CHANNEL_ID),
    }),
  });

  const body = await response.json();
  if (!response.ok) {
    return res.status(502).json({ error: body.error?.message ?? "Payment failed" });
  }

  // 201 Created → { data: { uuid, status: "pending", platform_fee, ... } }
  res.status(201).json({ uuid: body.data.uuid, status: body.data.status });
});

const PORT = process.env.PORT ?? 3000;
app.listen(PORT, () =>
  console.log(`Checkout API listening on http://localhost:${PORT}`)
);

Notes for first-timers:

  • We use CommonJS require() rather than import so the file runs on npm's default setup with zero extra configuration
  • process.env.FORTPESA_API_KEY reads straight from your .env file
  • The response from FortPesa arrives as { data: { uuid, status: "pending", ... } } — persist that uuid against the order immediately, it is the key for every lookup that follows

The project now looks like:

fortpesa-checkout/
├── node_modules/     created by npm — never edit or commit this
├── src/
│   └── index.js      all the server code lives here
├── .env              your secrets — never commit or share
├── .gitignore
├── package.json
└── package-lock.json

Step 4 — Run it and send your first payment request

Start the server:

node src/index.js

You should see exactly:

Checkout API listening on http://localhost:3000

Open a second terminal (leave the server running in the first) and fire a checkout request:

curl -X POST http://localhost:3000/api/checkout \
  -H "Content-Type: application/json" \
  -d '{"amount": 150, "phone": "0712345678", "reference": "INV-2041"}'

The reply should look like:

{ "uuid": "9f1c4a5e-2f77-4a0e-9c3b-1d5f6a7b8c9d", "status": "pending" }

When you swap in your own details, remember this API moves real money — a small amount from your own phone is the cleanest first run.

Step 5 — Receive the signed webhook

A webhook is simply FortPesa's server making an HTTP request to your server when something happens: payment collected, declined, cancelled, expired, or reversed. The events are named transaction.success, transaction.failed, transaction.cancelled, transaction.expired, and transaction.reversed.

Every delivery is HMAC-signed, and signature verification is where most integrations go wrong. Each event arrives with two headers, X-Fortpesa-Timestamp and X-Fortpesa-Signature, where:

signature = "sha256=" + hex(HMAC_SHA256(timestamp + "." + raw_body, whsec_YOUR_SECRET))

The trap is raw_body. If your JSON parser consumes the request stream first, the exact bytes are gone and the signature will never match. Make two edits to src/index.js.

Edit 1 — replace this line near the top:

app.use(express.json());

with this version, which stashes the untouched bytes as req.rawBody while parsing:

app.use(
  express.json({
    verify: (req, buf) => {
      req.rawBody = buf;
    },
  })
);

Edit 2 — add the crypto import next to the express import:

const crypto = require("node:crypto");

Edit 3 — add this function and route above the app.listen(...) lines at the bottom:

function verifySignature(req) {
  const timestamp = req.get("X-Fortpesa-Timestamp");
  const signature = req.get("X-Fortpesa-Signature");
  if (!timestamp || !signature) return false;

  // Replay window: 5 minutes
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected =
    "sha256=" +
    crypto
      .createHmac("sha256", process.env.FORTPESA_WEBHOOK_SECRET)
      .update(`${timestamp}.${req.rawBody.toString()}`)
      .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b); // constant time
}

app.post("/webhooks/fortpesa", (req, res) => {
  if (!verifySignature(req)) return res.status(400).end();

  const { event, data } = req.body;
  if (event === "transaction.success") {
    console.log(`Webhook received: ${data.uuid} reported success`);
    // TODO in Step 6: confirm the transaction, then fulfil the order
  }
  res.status(200).end(); // any 2xx, fast
});

Checkpoint — give your localhost a public URL. FortPesa cannot reach http://localhost:3000 from the internet, so during development run a tunnel in a third terminal:

npx localtunnel --port 3000

That prints a public https://...loca.lt URL (ngrok works too: ngrok http 3000). Register that URL with your webhook path — for example https://your-tunnel.loca.lt/webhooks/fortpesa — together with your webhook secret under Settings → Webhooks in the FortPesa dashboard.

Two operational facts to build around: FortPesa waits 10 seconds for a 2xx before treating a delivery as failed, then retries at roughly 10s, 30s, 60s, and 120s — five attempts in total. After 20 consecutive failures the webhook is disabled automatically; you bring it back with the toggle in Settings → Webhooks once your endpoint is healthy. Respond fast and push heavy work into a queue.

Step 6 — Confirm, then fulfil

A verified webhook tells you an event happened. The docs add one more rule before you fulfil an order: re-fetch the transaction with GET /api/v1/transactions/{uuid} and act only on what that read says. This closes the gap if a webhook is ever late, replayed, or missing — the polling fallback uses the same endpoint.

Hands using a smartphone next to a payment terminal for a cashless payment

Add this function above app.listen(...):

async function confirmTransaction(uuid) {
  const res = await fetch(`https://fortpesa.co.ke/api/v1/transactions/${uuid}`, {
    headers: { Authorization: `Bearer ${process.env.FORTPESA_API_KEY}` },
  });
  const { data } = await res.json();
  return data; // { status, amount, account_reference, settled_at, ... }
}

Then replace the // TODO in Step 6 comment inside the webhook route with:

    const tx = await confirmTransaction(data.uuid);
    console.log(`Confirmed: ${tx.account_reference} is ${tx.status}`);
    if (tx.status === "success") {
      await fulfilOrder(tx.account_reference); // your existing fulfilment logic
    }

You can also watch a transaction by hand at any time — the uuid came back in Step 4:

curl https://fortpesa.co.ke/api/v1/transactions/YOUR_UUID \
  -H "Authorization: Bearer pb_live_YOUR_KEY_HERE"

A transaction moves through pending and processing, then lands in a terminal state: success, failed, cancelled, expired, or reversed. Only terminal states should trigger fulfilment or refund logic. The webhook payload carries the full picture — amount, currency, platform_fee, phone, account_reference, provider_transaction_id, settled_at — so account_reference is your bridge back to the order in your database.

Step 7 — Test the whole flow end to end

With everything wired up, restart the server so your edits take effect (Ctrl+C to stop it, then node src/index.js again), keep the tunnel running, then:

  1. Send the checkout request from Step 4 with your own phone number and a small amount
  2. Approve the M-Pesa prompt on the phone
  3. Watch the server terminal — you should see the two log lines: Webhook received: ... reported success then Confirmed: INV-2041 is success
  4. Check the same transaction with the curl command from Step 6 — status success

If line 3 never appears, the webhook is the suspect: re-check the tunnel URL registered in the dashboard and that FORTPESA_WEBHOOK_SECRET matches. If line 3 appears but the status is not success, the payment itself failed — the failure_code in the payload says why.

When something goes wrong

Every failure comes back as { "error": { "code": "...", "message": "..." } }, which makes branching trivial:

  • 401 UNAUTHENTICATED — no key on the request
  • 401 INVALID_API_KEY — key unknown, malformed, or revoked. If a key leaks, revoke it in Settings → API keys and issue a replacement; revoked keys stop working immediately
  • 403 MERCHANT_INACTIVE — the key is valid but the merchant account is not active
  • 429 — you crossed the 120 req/min limit; back off and cache

Where to go next

The whole loop is small: create the push, persist the uuid, verify the signature, re-fetch the transaction, fulfil. Keep the API key and webhook secret in environment variables forever — not in code, not in Git, never in a browser.

If you would rather not maintain raw API code, FortPesa offers ready-made integrations: a PHP SDK, a WooCommerce plugin, an Easy Digital Downloads extension, and the no-code FormPesa form builder. And if your storefront runs on WooCommerce rather than custom code, our LipaWoo setup guide covers the no-code route. For the browser side of this flow, our earlier tutorial covers initiating the STK Push from React. The full API reference lives at fortpesa.co.ke/docs.

7.7Ksubscribers
50+tutorials
Freeforever

You made it to the end.

Most people don't. Since you're here: we teach this stuff on YouTube, free, every week. 7.7K developers already watch. Pull up a chair.

Watch on @fortunedevacademy

READY TO START
A PROJECT?

Let's discuss your next project.

START A PROJECT