Skip to content

Commit 8254145

Browse files
authored
feat: webhook relay server (v0.2.0)
feat: webhook relay server (v0.2.0)
2 parents 0c729dd + ba5ae90 commit 8254145

11 files changed

Lines changed: 1035 additions & 17 deletions

File tree

README.md

Lines changed: 158 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
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.
44

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.
14+
515
---
616

717
## Installation
@@ -14,7 +24,7 @@ Node.js 18+ required (uses native `fetch`).
1424

1525
---
1626

17-
## Quick Start
27+
## Quick Start — Library Mode
1828

1929
```typescript
2030
import { MpesaStk, PostgresAdapter } from 'mpesa-stk'
@@ -63,6 +73,141 @@ const reconciliation = await mpesa.reconcile(
6373

6474
---
6575

76+
## Relay Server Mode
77+
78+
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
82+
83+
(retries with backoff if your app is down)
84+
```
85+
86+
### Run with Docker / standalone
87+
88+
```bash
89+
DATABASE_URL=postgres://user:pass@host/db PORT=3000 npx mpesa-stk-relay
90+
```
91+
92+
On first run it creates the `relay_apps` and `relay_delivery_events` tables automatically.
93+
94+
### Register your app
95+
96+
```bash
97+
curl -X POST https://your-relay-domain.com/apps \
98+
-H 'Content-Type: application/json' \
99+
-d '{ "targetUrl": "https://yourapp.com/mpesa/callback" }'
100+
```
101+
102+
Response:
103+
104+
```json
105+
{
106+
"appId": "3f4a1c2d-...",
107+
"signingSecret": "a3f9b2c1...",
108+
"hookUrl": "/hooks/3f4a1c2d-...",
109+
"createdAt": "2026-04-03T10:00:00.000Z"
110+
}
111+
```
112+
113+
Store `signingSecret` somewhere safe — it's shown once. This is what you use to verify incoming webhooks and to update your target URL later.
114+
115+
Set your Safaricom `CallbackURL` to:
116+
117+
```
118+
https://your-relay-domain.com/hooks/3f4a1c2d-...
119+
```
120+
121+
### Verify webhook signatures in your app
122+
123+
Every delivery attempt includes an `X-Mpesa-Signature` header. Verify it before trusting the payload:
124+
125+
```typescript
126+
import { verifySignature } from 'mpesa-stk/server'
127+
128+
app.post('/mpesa/callback', (req, res) => {
129+
const body = JSON.stringify(req.body) // or the raw body string
130+
const sig = req.headers['x-mpesa-signature'] as string
131+
132+
if (!verifySignature(body, process.env.MPESA_RELAY_SECRET!, sig)) {
133+
return res.status(401).end()
134+
}
135+
136+
res.json({ ResultCode: 0, ResultDesc: 'Success' })
137+
// process the callback...
138+
})
139+
```
140+
141+
Safaricom does not sign its callbacks. The relay does. If you skip verification, anyone who discovers your callback URL can POST fake success payloads.
142+
143+
### Update your target URL
144+
145+
```bash
146+
curl -X PATCH https://your-relay-domain.com/apps/3f4a1c2d-... \
147+
-H 'Authorization: Bearer <signingSecret>' \
148+
-H 'Content-Type: application/json' \
149+
-d '{ "targetUrl": "https://newapp.com/mpesa/callback" }'
150+
```
151+
152+
### Check delivery status
153+
154+
```bash
155+
curl 'https://your-relay-domain.com/status/ws_CO_050420261030...?app_id=3f4a1c2d-...'
156+
```
157+
158+
Response:
159+
160+
```json
161+
{
162+
"eventId": "...",
163+
"checkoutRequestId": "ws_CO_050420261030...",
164+
"status": "DELIVERED",
165+
"attemptCount": 2,
166+
"deliveredAt": "2026-04-03T10:01:35.000Z",
167+
"lastError": null
168+
}
169+
```
170+
171+
Possible `status` values: `PENDING`, `DELIVERED`, `FAILED`, `DEAD`.
172+
`DEAD` means the relay exhausted all 6 attempts — check `lastError` to see what your app was returning.
173+
174+
### Retry schedule
175+
176+
| Attempt | Delay after previous failure |
177+
|---------|------------------------------|
178+
| 1 | Immediate |
179+
| 2 | 30 seconds |
180+
| 3 | 2 minutes |
181+
| 4 | 10 minutes |
182+
| 5 | 30 minutes |
183+
| 6 | 2 hours |
184+
|| Dead-lettered |
185+
186+
### Embed the relay in your own server
187+
188+
If you'd rather run the relay as part of an existing Node.js app instead of standalone:
189+
190+
```typescript
191+
import { createRelayServer, PostgresRelayAdapter, recoverPendingDeliveries } from 'mpesa-stk/server'
192+
import { serve } from '@hono/node-server'
193+
import { Pool } from 'pg'
194+
195+
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
196+
const storage = new PostgresRelayAdapter(pool)
197+
198+
await storage.migrate()
199+
await recoverPendingDeliveries(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.
208+
209+
---
210+
66211
## Environment Variables
67212

68213
| Variable | Description |
@@ -71,15 +216,19 @@ const reconciliation = await mpesa.reconcile(
71216
| `MPESA_CONSUMER_SECRET` | Daraja app consumer secret |
72217
| `MPESA_SHORTCODE` | Your M-Pesa shortcode (paybill or till number) |
73218
| `MPESA_PASSKEY` | STK Push passkey from the Daraja portal |
74-
| `MPESA_CALLBACK_URL` | Publicly reachable HTTPS URL that receives STK callbacks |
219+
| `MPESA_CALLBACK_URL` | Set this to your relay's `/hooks/<appId>` URL |
75220
| `MPESA_ENVIRONMENT` | `sandbox` or `production` |
221+
| `DATABASE_URL` | PostgreSQL connection string (relay server only) |
222+
| `PORT` | Port for the relay server (default: 3000) |
76223

77-
The library does not read environment variables directly. Pass values via `MpesaConfig`.
224+
The library itself does not read environment variables. Pass values explicitly.
78225

79226
---
80227

81228
## Database Setup
82229

230+
### Library tables (mpesa_payments)
231+
83232
Run this once before starting your server:
84233

85234
```sql
@@ -107,6 +256,10 @@ CREATE INDEX IF NOT EXISTS mpesa_payments_status_initiated
107256

108257
Or call `await adapter.migrate()` on startup — it uses `IF NOT EXISTS` and is safe to call repeatedly.
109258

259+
### Relay tables (relay_apps, relay_delivery_events)
260+
261+
Created automatically when you run `await storage.migrate()` or start `npx mpesa-stk-relay`.
262+
110263
---
111264

112265
## Configuration
@@ -119,7 +272,7 @@ All fields in `MpesaConfig`:
119272
| `consumerSecret` | `string` || Daraja consumer secret |
120273
| `shortCode` | `string` || Your M-Pesa shortcode |
121274
| `passKey` | `string` || STK Push passkey |
122-
| `callbackUrl` | `string` || Your callback endpoint |
275+
| `callbackUrl` | `string` || Your callback endpoint (or relay hook URL) |
123276
| `environment` | `'sandbox' \| 'production'` || Controls which Daraja URLs are used |
124277
| `timeoutMs` | `number` | `75000` | HTTP timeout for all Daraja requests |
125278
| `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
171324
- How to detect when your database says `SUCCESS` but Safaricom has no record of it
172325
- How to handle the phone number being masked in callbacks from 2026 onward
173326

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.
175328

176329
---
177330

bin/serve.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
#!/usr/bin/env node
2+
/**
3+
* mpesa-stk-relay — standalone webhook relay server
4+
*
5+
* Usage:
6+
* DATABASE_URL=postgres://... PORT=3000 npx mpesa-stk-relay
7+
*
8+
* On first run the relay tables are created automatically. Point your Safaricom
9+
* CallbackURL at /hooks/<your-app-id> after registering via POST /apps.
10+
*/
11+
12+
import { Pool } from 'pg'
13+
import { serve } from '@hono/node-server'
14+
import { createRelayServer } from '../src/server/relay.js'
15+
import { PostgresRelayAdapter } from '../src/server/registry.js'
16+
import { recoverPendingDeliveries } from '../src/server/delivery.js'
17+
18+
const databaseUrl = process.env['DATABASE_URL']
19+
if (!databaseUrl) {
20+
console.error('[mpesa-stk-relay] DATABASE_URL is required')
21+
process.exit(1)
22+
}
23+
24+
const port = parseInt(process.env['PORT'] ?? '3000', 10)
25+
26+
const pool = new Pool({ connectionString: databaseUrl })
27+
28+
const storage = new PostgresRelayAdapter(pool)
29+
30+
// Structured enough to be useful without pulling in a logging library
31+
const logger = {
32+
info: (msg: string, meta?: Record<string, unknown>) => console.log(JSON.stringify({ level: 'info', msg, ...meta, ts: new Date().toISOString() })),
33+
warn: (msg: string, meta?: Record<string, unknown>) => console.log(JSON.stringify({ level: 'warn', msg, ...meta, ts: new Date().toISOString() })),
34+
error: (msg: string, meta?: Record<string, unknown>) => console.error(JSON.stringify({ level: 'error', msg, ...meta, ts: new Date().toISOString() })),
35+
}
36+
37+
async function main() {
38+
// Run migrations first — safe to call on every startup
39+
await storage.migrate()
40+
logger.info('Database tables ready')
41+
42+
// Reschedule any deliveries that were in-flight when the server last stopped
43+
await recoverPendingDeliveries(storage, logger)
44+
45+
const app = createRelayServer({ storage, logger })
46+
47+
serve({ fetch: app.fetch, port }, () => {
48+
logger.info(`mpesa-stk-relay listening`, { port })
49+
logger.info('Register an app via: POST /apps { "targetUrl": "https://yourapp.com/mpesa/callback" }')
50+
})
51+
52+
// Periodic sweep every 60 seconds to catch any events that slipped through
53+
// (e.g. if a setTimeout was lost due to a JS event loop anomaly)
54+
setInterval(() => {
55+
void recoverPendingDeliveries(storage, logger)
56+
}, 60_000)
57+
58+
process.on('SIGTERM', async () => {
59+
logger.info('Shutting down')
60+
await pool.end()
61+
process.exit(0)
62+
})
63+
}
64+
65+
main().catch((err) => {
66+
console.error('[mpesa-stk-relay] Fatal startup error:', err)
67+
process.exit(1)
68+
})

package-lock.json

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
"main": "./dist/index.js",
66
"module": "./dist/index.mjs",
77
"types": "./dist/index.d.ts",
8+
"bin": {
9+
"mpesa-stk-relay": "./dist/bin/serve.js"
10+
},
811
"exports": {
912
".": {
1013
"import": {
@@ -35,6 +38,16 @@
3538
"types": "./dist/adapters/postgres.d.ts",
3639
"default": "./dist/adapters/postgres.js"
3740
}
41+
},
42+
"./server": {
43+
"import": {
44+
"types": "./dist/server/index.d.mts",
45+
"default": "./dist/server/index.mjs"
46+
},
47+
"require": {
48+
"types": "./dist/server/index.d.ts",
49+
"default": "./dist/server/index.js"
50+
}
3851
}
3952
},
4053
"files": [
@@ -50,6 +63,8 @@
5063
"prepublishOnly": "npm run typecheck && npm run test && npm run build"
5164
},
5265
"dependencies": {
66+
"@hono/node-server": "^1.19.12",
67+
"hono": "^4.12.10",
5368
"pg": "^8.11.0"
5469
},
5570
"devDependencies": {
@@ -74,6 +89,9 @@
7489
"safaricom",
7590
"payments",
7691
"kenya",
77-
"typescript"
92+
"typescript",
93+
"webhook",
94+
"webhook-relay",
95+
"callback"
7896
]
7997
}

0 commit comments

Comments
 (0)