You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: README.md
+158-5Lines changed: 158 additions & 5 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -2,6 +2,16 @@
2
2
3
3
TypeScript 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.
4
4
5
+
**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.
6
+
7
+
---
8
+
9
+
## The problem
10
+
11
+
Safaricom 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."
12
+
13
+
The 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.
Instead 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.
79
+
80
+
```
81
+
Safaricom → relay /hooks/<appId> → your app POST /mpesa/callback
Safaricom does not sign its callbacks. The relay does. If you skip verification, anyone who discovers your callback URL can POST fake success payloads.
const pool =newPool({ connectionString: process.env.DATABASE_URL })
196
+
const storage =newPostgresRelayAdapter(pool)
197
+
198
+
awaitstorage.migrate()
199
+
awaitrecoverPendingDeliveries(storage) // reschedule any in-flight retries
200
+
201
+
const relay =createRelayServer({ storage })
202
+
203
+
// Mount on any path you control
204
+
serve({ fetch: relay.fetch, port: 3000 })
205
+
```
206
+
207
+
The `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.
|`environment`|`'sandbox' \| 'production'`| — | Controls which Daraja URLs are used |
124
277
|`timeoutMs`|`number`|`75000`| HTTP timeout for all Daraja requests |
125
278
|`maxPollAttempts`|`number`|`10`| How many STK Query attempts before marking TIMEOUT |
@@ -171,7 +324,7 @@ The Daraja API gives you a way to send an STK Push and receive a callback. It le
171
324
- How to detect when your database says `SUCCESS` but Safaricom has no record of it
172
325
- How to handle the phone number being masked in callbacks from 2026 onward
173
326
174
-
This library handles those. It does not handle B2C, C2B registration, balance queries, reversals, or any Daraja endpoint other than STK Push initiation and STK Push query.
327
+
This 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.
0 commit comments