npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

mpesa-stk

v0.3.1

Published

Reliability-focused STK Push lifecycle manager: idempotency, deduplication, polling fallback, and reconciliation

Downloads

53

Readme

mpesa-stk

The M-Pesa STK Push lifecycle, done properly. Daraja gives you "send a push" and "receive one callback" — this library is everything in between: idempotent initiation, atomic callback dedup, a polling fallback, and reconciliation.

TypeScript · Node 18+ · Postgres. STK Push only — no B2C, C2B, balance, or reversals.

Why

Daraja's docs are a request/response spec. They stop where production starts. Pointed at the live sandbox, the API actually:

  • re-fires the same callback multiple times under load — dedupe or you double-credit
  • drops the callback entirely if your server blinks — no retry, no dead-letter
  • rate-limits the STK Query (5 req/min in sandbox) and returns undocumented transient codes like 4999 mid-flight — a naive poller marks a still-pending payment FAILED
  • masks the customer's phone number in 2026+ callbacks
  • has no idempotency key — a double-tapped "Pay" sends two pushes and charges twice

Each of those is a wrong refund or a missed order waiting to happen. This library is that missing layer.

Install

npm install mpesa-stk pg

Usage

import { MpesaStk, PostgresAdapter } from 'mpesa-stk'
import { Pool } from 'pg'

const adapter = new PostgresAdapter(new Pool({ connectionString: process.env.DATABASE_URL }))
await adapter.migrate() // creates mpesa_payments; safe on every startup (IF NOT EXISTS)

const mpesa = new MpesaStk(
  {
    consumerKey:    process.env.MPESA_CONSUMER_KEY!,
    consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
    shortCode:      process.env.MPESA_SHORTCODE!,
    passKey:        process.env.MPESA_PASSKEY!,
    callbackUrl:    process.env.MPESA_CALLBACK_URL!,
    environment:    'sandbox',
  },
  adapter
)

// Fires once when a payment reaches a terminal state — via callback or poll
mpesa.onPaymentSettled(async (p) => console.log(p.id, p.status, p.mpesaReceiptNumber))

// idempotencyKey makes a retried/double-tapped request safe
const payment = await mpesa.initiatePayment({
  phoneNumber: '0712345678',
  amount: 500,
  accountReference: 'ORDER-123',
  description: 'Payment for ORDER-123',
  idempotencyKey: 'ORDER-123',
})

// Webhook route — ACK Safaricom first (within 5s), then process
app.post('/mpesa/callback', async (req, res) => {
  res.json({ ResultCode: 0, ResultDesc: 'Success' })
  await mpesa.processCallback(req.body)
})

// Reconcile on a schedule, not per request
await mpesa.reconcile(new Date(Date.now() - 864e5), new Date(Date.now() - 3e5))

Config: timeoutMs (75000), pollIntervalMs (5000 — base of the poll backoff), maxPollAttempts (10).

How it works

  • Callback dedupsettlePayment is one SQL compare-and-swap (UPDATE … WHERE status = 'PENDING'), not a read-then-check. Exactly one of N racing callbacks wins; onPaymentSettled fires once. The same guard covers poll-vs-callback.
  • Idempotent initiation — concurrent same-key calls share one in-flight STK Push; across processes, a UNIQUE constraint on the key (written in the same INSERT as the record) is the backstop.
  • Poll fallback — Fibonacci backoff from pollIntervalMs (5s → 10s → 15s → 25s → 30s), checks storage before each query, and settles only on a known-terminal code — transient/unknown codes keep waiting instead of failing a live payment.
  • Reconciliation — checks every status, not just PENDING, because drift runs both ways:

| Your record | Daraja STK Query | What it means | reconcile result | |---|---|---|---| | PENDING | SUCCESS | callback + poll both missed it; customer paid | mismatch | | TIMEOUT / FAILED | SUCCESS | you gave up or wrote it off, but money moved — the dangerous one (you may refund a paid order) | mismatch | | SUCCESS | a failure code | a recorded success Daraja contradicts | mismatch | | SUCCESS | no record / query error | possible ghost credit or spoofed callback | skipped (unverifiable) | | any | transient 4999 / 429 | not yet determinable | skipped (re-run) |

Mismatches are returned, never auto-corrected — a stale query during a manual reversal would otherwise credit the wrong order. reconcile only settles drift it can prove; anything it can't verify is skipped, not guessed.

More depth: why callbacks fail · reconciliation · unverified Daraja behaviors.

Relay (optional)

Point Safaricom's CallbackURL at the relay instead of your app. It dedupes, persists, and redelivers one signed webhook with backoff (→ 30s → 2m → 10m → 30m → 2h → dead-letter), so a restart never loses a callback.

DATABASE_URL=… PORT=3000 npx mpesa-stk-relay

Safaricom doesn't sign callbacks; the relay does. Verify it, or anyone with your URL can POST fake successes:

import { verifySignature } from 'mpesa-stk/server'

if (!verifySignature(rawBody, process.env.MPESA_RELAY_SECRET!, sig)) return res.status(401).end()

Embedding, registration, and delivery-status endpoints: examples/.

Examples

MIT