A TypeScript engine for Paystack, Flutterwave, and M-Pesa STK Push — signed webhooks,
phone-number normalization, and background reconciliation for the transactions that
silently get stuck in PENDING.
07..., 254..., or +254...Pulled directly from integrating these providers in production — the parts that cost real debugging time, not the parts covered by the providers' own docs.
PENDING forever unless something actively checks on it.
// background reconciliation, runs on a schedule async function reconcilePendingTransactions() { const pending = await db.transaction.findMany({ where: { status: 'PENDING' } }); for (const tx of pending) { const result = await queryMpesaExpressStatus(tx.checkoutRequestId); if (result.ResultCode === '0') { await yourFulfilmentHook(tx); // grant the purchase first await db.transaction.update({ where: { id: tx.id }, data: { status: 'SUCCESS' } }); // only then mark settled } } }
+254XXXXXXXXX).
Users type 0712..., 254712..., or the full international
format interchangeably — and a bad regex fails silently mid-request.
export function normalizeKenyanPhone(phone: string): string { let cleaned = phone.replace(/[^\d+]/g, ''); if (cleaned.startsWith('0')) cleaned = `+254${cleaned.slice(1)}`; else if (cleaned.startsWith('254')) cleaned = `+${cleaned}`; else if (!cleaned.startsWith('+254')) throw new Error(`Invalid phone: ${phone}`); return cleaned; }
export const PaystackWebhookSchema = z.object({ event: z.string(), data: z.object({ status: z.enum(['success', 'failed', 'abandoned']), reference: z.string(), amount: z.number(), metadata: z.object({ tenantId: z.string() }).passthrough(), }), }); // + raw-buffer HMAC signature check before this schema ever runs
Each tier includes everything below it. Single-developer commercial license; resale or public re-hosting of the source isn't permitted.