{"id":48448729,"url":"https://github.com/ronnyabuto/mpesa-stk","last_synced_at":"2026-04-06T19:03:16.271Z","repository":{"id":346164199,"uuid":"1188120766","full_name":"ronnyabuto/mpesa-stk","owner":"ronnyabuto","description":"TypeScript library for the M-Pesa STK Push lifecycle with built-in webhook relay server.","archived":false,"fork":false,"pushed_at":"2026-04-03T19:05:22.000Z","size":124,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-04-03T19:36:21.089Z","etag":null,"topics":["daraja","daraja-api","fintech-kenya","kenya","mobile-money","mpesa","mpesa-api","nodejs","payments","safaricom","stk-push","typescript"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/ronnyabuto.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-21T16:39:43.000Z","updated_at":"2026-04-03T19:23:50.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/ronnyabuto/mpesa-stk","commit_stats":null,"previous_names":["ronnyabuto/mpesa-stk"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/ronnyabuto/mpesa-stk","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ronnyabuto%2Fmpesa-stk","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ronnyabuto%2Fmpesa-stk/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ronnyabuto%2Fmpesa-stk/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ronnyabuto%2Fmpesa-stk/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/ronnyabuto","download_url":"https://codeload.github.com/ronnyabuto/mpesa-stk/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ronnyabuto%2Fmpesa-stk/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31485516,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-06T17:22:55.647Z","status":"ssl_error","status_checked_at":"2026-04-06T17:22:54.741Z","response_time":112,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["daraja","daraja-api","fintech-kenya","kenya","mobile-money","mpesa","mpesa-api","nodejs","payments","safaricom","stk-push","typescript"],"created_at":"2026-04-06T19:03:05.150Z","updated_at":"2026-04-06T19:03:16.262Z","avatar_url":"https://github.com/ronnyabuto.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# mpesa-stk\n\nTypeScript library for the M-Pesa STK Push lifecycle. Handles the parts the Daraja API leaves to you: idempotent initiation, atomic callback deduplication, polling fallback, and reconciliation.\n\n**New in this version:** a built-in webhook relay server. Point your Safaricom `CallbackURL` at the relay, and it handles guaranteed delivery with exponential-backoff retries to your app — just like Stripe webhooks, but for Daraja.\n\n---\n\n## The problem\n\nSafaricom fires your `CallbackURL` once. If your server is restarting, behind a CDN that rate-limits their IP, or just slow to respond — the callback is silently dropped. No retry, no dead-letter queue, no notification. You find out from a customer who says \"I paid but nothing happened.\"\n\nThe polling fallback in `MpesaStk` catches a lot of that. But polling only works if your server is up. The relay catches what polling can't: the gap between when the callback was sent and when your server came back online.\n\n---\n\n## Installation\n\n```bash\nnpm install mpesa-stk pg\n```\n\nNode.js 18+ required (uses native `fetch`).\n\n---\n\n## Quick Start — Library Mode\n\n```typescript\nimport { MpesaStk, PostgresAdapter } from 'mpesa-stk'\nimport { Pool } from 'pg'\n\nconst pool = new Pool({ connectionString: process.env.DATABASE_URL })\nconst adapter = new PostgresAdapter(pool)\n\n// Creates the mpesa_payments table if it doesn't exist. Safe to call on\n// every startup — all DDL uses IF NOT EXISTS.\nawait adapter.migrate()\n\nconst mpesa = new MpesaStk(\n  {\n    consumerKey:    process.env.MPESA_CONSUMER_KEY!,\n    consumerSecret: process.env.MPESA_CONSUMER_SECRET!,\n    shortCode:      process.env.MPESA_SHORTCODE!,\n    passKey:        process.env.MPESA_PASSKEY!,\n    callbackUrl:    process.env.MPESA_CALLBACK_URL!,\n    environment:    'sandbox',\n  },\n  adapter\n)\n\n// Fires when a payment reaches a terminal state — via callback or polling\nmpesa.onPaymentSettled(async (payment) =\u003e {\n  console.log(payment.id, payment.status, payment.mpesaReceiptNumber)\n  // update your order system here\n})\n\n// Initiate — idempotencyKey prevents a double-charge if the request is retried\nconst payment = await mpesa.initiatePayment({\n  phoneNumber:      '0712345678',\n  amount:           500,\n  accountReference: 'ORDER-123',\n  description:      'Payment for order ORDER-123',\n  idempotencyKey:   'ORDER-123',\n})\n\n// Callback route — respond to Safaricom before doing anything else\napp.post('/mpesa/callback', async (req, res) =\u003e {\n  res.json({ ResultCode: 0, ResultDesc: 'Success' }) // must be within 5 seconds\n  await mpesa.processCallback(req.body)\n})\n\n// Reconciliation — run on a schedule, not on every request\nconst reconciliation = await mpesa.reconcile(\n  new Date(Date.now() - 24 * 60 * 60 * 1000), // 24 hours ago\n  new Date(Date.now() -  5 * 60 * 1000)        // 5 minutes ago\n)\n```\n\n---\n\n## Relay Server Mode\n\nInstead of pointing Safaricom's `CallbackURL` directly at your app, you point it at the relay. The relay validates the callback, deduplicates it, persists it, and then delivers it to your app with exponential-backoff retries. Your app gets the same signed webhook regardless of whether Safaricom fired once or four times.\n\n```\nSafaricom → relay /hooks/\u003cappId\u003e → your app POST /mpesa/callback\n                       ↑\n              (retries with backoff if your app is down)\n```\n\n### Run with Docker / standalone\n\n```bash\nDATABASE_URL=postgres://user:pass@host/db PORT=3000 npx mpesa-stk-relay\n```\n\nOn first run it creates the `relay_apps` and `relay_delivery_events` tables automatically.\n\n### Register your app\n\n```bash\ncurl -X POST https://your-relay-domain.com/apps \\\n  -H 'Content-Type: application/json' \\\n  -d '{ \"targetUrl\": \"https://yourapp.com/mpesa/callback\" }'\n```\n\nResponse:\n\n```json\n{\n  \"appId\": \"3f4a1c2d-...\",\n  \"signingSecret\": \"a3f9b2c1...\",\n  \"hookUrl\": \"/hooks/3f4a1c2d-...\",\n  \"createdAt\": \"2026-04-03T10:00:00.000Z\"\n}\n```\n\nStore `signingSecret` somewhere safe — it's shown once. This is what you use to verify incoming webhooks and to update your target URL later.\n\nSet your Safaricom `CallbackURL` to:\n\n```\nhttps://your-relay-domain.com/hooks/3f4a1c2d-...\n```\n\n### Verify webhook signatures in your app\n\nEvery delivery attempt includes an `X-Mpesa-Signature` header. Verify it before trusting the payload:\n\n```typescript\nimport { verifySignature } from 'mpesa-stk/server'\n\napp.post('/mpesa/callback', (req, res) =\u003e {\n  const body = JSON.stringify(req.body) // or the raw body string\n  const sig  = req.headers['x-mpesa-signature'] as string\n\n  if (!verifySignature(body, process.env.MPESA_RELAY_SECRET!, sig)) {\n    return res.status(401).end()\n  }\n\n  res.json({ ResultCode: 0, ResultDesc: 'Success' })\n  // process the callback...\n})\n```\n\nSafaricom does not sign its callbacks. The relay does. If you skip verification, anyone who discovers your callback URL can POST fake success payloads.\n\n### Update your target URL\n\n```bash\ncurl -X PATCH https://your-relay-domain.com/apps/3f4a1c2d-... \\\n  -H 'Authorization: Bearer \u003csigningSecret\u003e' \\\n  -H 'Content-Type: application/json' \\\n  -d '{ \"targetUrl\": \"https://newapp.com/mpesa/callback\" }'\n```\n\n### Check delivery status\n\n```bash\ncurl 'https://your-relay-domain.com/status/ws_CO_050420261030...?app_id=3f4a1c2d-...'\n```\n\nResponse:\n\n```json\n{\n  \"eventId\": \"...\",\n  \"checkoutRequestId\": \"ws_CO_050420261030...\",\n  \"status\": \"DELIVERED\",\n  \"attemptCount\": 2,\n  \"deliveredAt\": \"2026-04-03T10:01:35.000Z\",\n  \"lastError\": null\n}\n```\n\nPossible `status` values: `PENDING`, `DELIVERED`, `FAILED`, `DEAD`.  \n`DEAD` means the relay exhausted all 6 attempts — check `lastError` to see what your app was returning.\n\n### Retry schedule\n\n| Attempt | Delay after previous failure |\n|---------|------------------------------|\n| 1       | Immediate                    |\n| 2       | 30 seconds                   |\n| 3       | 2 minutes                    |\n| 4       | 10 minutes                   |\n| 5       | 30 minutes                   |\n| 6       | 2 hours                      |\n| —       | Dead-lettered                |\n\n### Embed the relay in your own server\n\nIf you'd rather run the relay as part of an existing Node.js app instead of standalone:\n\n```typescript\nimport { createRelayServer, PostgresRelayAdapter, recoverPendingDeliveries } from 'mpesa-stk/server'\nimport { serve } from '@hono/node-server'\nimport { Pool } from 'pg'\n\nconst pool = new Pool({ connectionString: process.env.DATABASE_URL })\nconst storage = new PostgresRelayAdapter(pool)\n\nawait storage.migrate()\nawait recoverPendingDeliveries(storage) // reschedule any in-flight retries\n\nconst relay = createRelayServer({ storage })\n\n// Mount on any path you control\nserve({ fetch: relay.fetch, port: 3000 })\n```\n\nThe `createRelayServer()` function returns a [Hono](https://hono.dev/) app — you can mount it inside Express, serve it on Cloudflare Workers, or wrap it in Bun.\n\n---\n\n## Environment Variables\n\n| Variable | Description |\n|---|---|\n| `MPESA_CONSUMER_KEY` | Daraja app consumer key |\n| `MPESA_CONSUMER_SECRET` | Daraja app consumer secret |\n| `MPESA_SHORTCODE` | Your M-Pesa shortcode (paybill or till number) |\n| `MPESA_PASSKEY` | STK Push passkey from the Daraja portal |\n| `MPESA_CALLBACK_URL` | Set this to your relay's `/hooks/\u003cappId\u003e` URL |\n| `MPESA_ENVIRONMENT` | `sandbox` or `production` |\n| `DATABASE_URL` | PostgreSQL connection string (relay server only) |\n| `PORT` | Port for the relay server (default: 3000) |\n\nThe library itself does not read environment variables. Pass values explicitly.\n\n---\n\n## Database Setup\n\n### Library tables (mpesa_payments)\n\nRun this once before starting your server:\n\n```sql\nCREATE TABLE IF NOT EXISTS mpesa_payments (\n  id                   TEXT PRIMARY KEY,\n  checkout_request_id  TEXT UNIQUE NOT NULL,\n  merchant_request_id  TEXT NOT NULL,\n  phone_number         TEXT NOT NULL,\n  amount               INTEGER NOT NULL,\n  account_reference    TEXT NOT NULL,\n  status               TEXT NOT NULL DEFAULT 'PENDING'\n                         CHECK (status IN ('PENDING','SUCCESS','FAILED','CANCELLED','TIMEOUT','EXPIRED')),\n  mpesa_receipt_number TEXT,\n  failure_reason       TEXT,\n  result_code          INTEGER,\n  initiated_at         TIMESTAMPTZ NOT NULL,\n  completed_at         TIMESTAMPTZ,\n  raw_callback         JSONB,\n  idempotency_key      TEXT UNIQUE\n);\n\nCREATE INDEX IF NOT EXISTS mpesa_payments_status_initiated\n  ON mpesa_payments(status, initiated_at);\n```\n\nOr call `await adapter.migrate()` on startup — it uses `IF NOT EXISTS` and is safe to call repeatedly.\n\n### Relay tables (relay_apps, relay_delivery_events)\n\nCreated automatically when you run `await storage.migrate()` or start `npx mpesa-stk-relay`.\n\n---\n\n## Configuration\n\nAll fields in `MpesaConfig`:\n\n| Field | Type | Default | Description |\n|---|---|---|---|\n| `consumerKey` | `string` | — | Daraja consumer key |\n| `consumerSecret` | `string` | — | Daraja consumer secret |\n| `shortCode` | `string` | — | Your M-Pesa shortcode |\n| `passKey` | `string` | — | STK Push passkey |\n| `callbackUrl` | `string` | — | Your callback endpoint (or relay hook URL) |\n| `environment` | `'sandbox' \\| 'production'` | — | Controls which Daraja URLs are used |\n| `timeoutMs` | `number` | `75000` | HTTP timeout for all Daraja requests |\n| `maxPollAttempts` | `number` | `10` | How many STK Query attempts before marking TIMEOUT |\n\n---\n\n## Payment Lifecycle\n\n```\ninitiatePayment()\n      │\n      ▼\n  [PENDING] ──────────────────────────────────────────────────────────┐\n      │                                                                │\n      │  callback arrives          poll finds terminal state          │\n      ▼                                  ▼                            │\n  [SUCCESS]                         [SUCCESS]                     maxPollAttempts\n  [FAILED]                          [FAILED]                      exhausted\n  [CANCELLED]                       [CANCELLED]                       │\n  [EXPIRED]                         [EXPIRED]                         ▼\n                                                                  [TIMEOUT]\n```\n\n`TIMEOUT` means your system gave up waiting, not that the payment failed. Run reconciliation — the customer may have paid after your polling window closed.\n\n---\n\n## Docs\n\n- [Why callbacks fail in production](./docs/why-callbacks-fail.md)\n- [Reconciliation strategy](./docs/reconciliation.md)\n\n---\n\n## Examples\n\n- [Next.js App Router](./examples/nextjs/)\n- [Express](./examples/express/server.ts)\n\n---\n\n## Why Not the Daraja SDK Directly?\n\nThe Daraja API gives you a way to send an STK Push and receive a callback. It leaves these to you:\n\n- What to do when the callback never arrives (network failure, your server was restarting, Safaricom dropped it)\n- How to handle Safaricom sending the same callback 2–4 times, which it does under load\n- How to prevent double-charging when a client retries the initiation request\n- How to detect when your database says `SUCCESS` but Safaricom has no record of it\n- How to handle the phone number being masked in callbacks from 2026 onward\n\nThis library handles those. The relay server handles the delivery reliability layer on top of that. Neither handles B2C, C2B registration, balance queries, reversals, or any Daraja endpoint other than STK Push.\n\n---\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fronnyabuto%2Fmpesa-stk","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fronnyabuto%2Fmpesa-stk","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fronnyabuto%2Fmpesa-stk/lists"}