@bevingh/payments
v0.1.0
Published
Mono-package (KD17): BaseProvider + in-tree drivers, verifyWebhookSignature(rawBody) HMAC helper, inbound verify-required contract, outbound dispatcher.
Readme
@bevingh/payments
Phase 3 / PR-16 — extracted (steps 1–4). Mono-package (KD17). Highest priority: raw-body HMAC verification.
Purpose
BaseProvider + in-tree drivers, verifyWebhookSignature(rawBody), inbound verify-required contract, outbound signed dispatcher. Not product fulfillment domain.
| Field | Value |
|---|---|
| surfaceShape | pure_core_plus_express_adapter |
| dependsOnPackages | @bevingh/errors |
| extractionOrderHint | 6 |
| status | extracted (steps 1–4) |
Step 1 — verifyWebhookSignature (do this first everywhere)
Paystack algorithm confirmed from conduit: HMAC-SHA512 over raw body, timingSafeEqual.
import { verifyWebhookSignature } from '@bevingh/payments';
// req.rawBody = Buffer from express raw capture — NOT req.body object
const ok = verifyWebhookSignature(req.rawBody, req.headers['x-paystack-signature'], secret);| Export | Role |
|---|---|
| WebhookRawBody | Buffer \| string only — objects rejected at runtime |
| assertWebhookRawBody | boundary guard |
| verifyWebhookSignature | sha512 default (Paystack) |
| verifyWebhookSignatureWithSecrets | try live + test keys |
Broken pattern (do not port): createHmac(...).update(JSON.stringify(req.body)) — UVT nomination, Academicx, imep, Texify, Didipay Moolre path.
Step 2 — Providers (KD17 mono-package)
| Driver | Source | Notes |
|---|---|---|
| PaystackProvider | conduit | verify uses Step 1; charge/checkout need injected http |
| KorbaProvider | payment-gatway korba.js | debit/verify remote; no HMAC in harvest |
| Moolre | deferred | needs_human_decision — Didipay vs mirrly trust models differ |
| Hubtel | deferred | no HMAC scheme in harvested imep code |
Step 3 — Inbound contract (verify required)
await processInboundWebhook({
rawBody: req.rawBody,
signature: req.headers['x-paystack-signature'],
secrets: [liveKey, testKey],
handle: async (body) => {
// only runs after HMAC ok
// prefer @bevingh/fulfillment attemptTransition for status flips
},
});There is no skipVerification flag. pg/ussd unsigned inbound is the gap this closes.
Status flips: compose with @bevingh/fulfillment attemptTransition (compatible shapes; soft dependency via docs only).
Step 4 — Outbound dispatcher
import { dispatchSignedWebhook, signOutboundWebhook } from '@bevingh/payments';
// Conduit → consumer: HMAC-SHA256 over JSON.stringify(payload), X-Conduit-Signature
await dispatchSignedWebhook({
url: product.webhookUrl,
payload: { event: 'payment.completed', ref },
secret: process.env.WEBHOOK_SECRET!,
post: async (url, body, headers) => axios.post(url, body, { headers }),
});Outbound does sign canonical JSON (producer-controlled). That is different from inbound Paystack raw-body verify.
Express adapter
@bevingh/payments/adapters/express → createPaystackWebhookMiddleware
Requires req.rawBody; fails loud if missing (no JSON.stringify(req.body) fallback).
Open questions (not silently resolved)
Moolre
Didipay uses HMAC over JSON.stringify; mirrly often trusts session gate only. No unified driver in this PR. Product decision required.
Hubtel (imep)
No signature verification in harvested controller. Needs external docs research for Hubtel’s real scheme — not fabricated here.
mustNotContain (verified)
| Constraint | Status | |---|---| | JSON.stringify HMAC for inbound verify | Forbidden / not implemented | | Ticket/vote/registration domain | OK | | Wallet balance mutation | OK | | Tax LedgerEntry | OK |
Tests
npm run test -w @bevingh/payments
npm run build -w @bevingh/payments- Valid / tampered body / tampered sig
- JSON.stringify vs raw bytes demonstration
- Inbound: handle never runs without verify
- Paystack checkout amount*100 with mock HTTP
- Korba debit mock
- Outbound sha256 sign + header
Champion files (read-only)
- conduit BaseProvider, PaystackProvider, webhooks.js, webhookDispatcherService.js
- payment-gatway korba.js
