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:
- Your server calls the FortPesa API to trigger an STK Push — the M-Pesa prompt on the customer's Safaricom line
- The customer enters their PIN; FortPesa records the outcome
- FortPesa calls your server back with a signed webhook
- Your server re-checks the transaction through the API, and only then fulfils the order

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-infetch, 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
- An API key: open Settings → API keys and create one. It looks like
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 -ycreates apackage.json(the-yaccepts all defaults so it runs non-interactively)expressis the web framework — it listens for HTTP requests so both your checkout page and FortPesa's webhooks can reach your codedotenvloads the.envfile 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 thanimportso the file runs on npm's default setup with zero extra configuration process.env.FORTPESA_API_KEYreads straight from your.envfile- 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.

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:
- Send the checkout request from Step 4 with your own phone number and a small amount
- Approve the M-Pesa prompt on the phone
- Watch the server terminal — you should see the two log lines:
Webhook received: ... reported successthenConfirmed: INV-2041 is success - 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.
