@bevingh/conduit-client
v0.1.0
Published
Typed client for Conduit's product-facing API: mobile money charges + OTP, card checkout, payment status, refund requests, pay-by-code, transaction/disbursement queries, and verification of Conduit's outbound webhook signature. Full product-facing surface
Readme
@bevingh/conduit-client
New-build, not a Phase 1 harvest. Built strictly to
conduit/docs/api-reference.md(the live technical reference for theconduitrepo), not to a remembered approximation of it — seeconduit/PROGRESS.md's "Planned Initiative" section for the full background on why this package exists and what it's meant to replace (each integrator hand-rolling HTTP calls against the docs, which is exactly the kind of duplication that produced a real bug inconduit-admin-app— a network-code mismatch only caught by a later audit).
Purpose
Typed client for Conduit's product-facing API (/api/v1/*) — the surface a product integrates against to take payments through Conduit, not the /admin/* operator API (product/recipient/subaccount setup — that stays a conduit-admin-app / scripts/cli.js concern) or /api/v1/ussd/* (internal router-only, never called directly by product integrations).
| Field | Value |
|---|---|
| surfaceShape | pure_core_plus_express_adapter |
| dependsOnPackages | @bevingh/payments (→ @bevingh/errors) |
| status | built (not published to npm this session) |
Full surface, not an MVP subset: initiate charge (+ OTP flow), card checkout, payment status, refund requests, pay-by-code generate/lookup/cancel, transaction + disbursement queries, and verification of Conduit's outbound webhook signature. Every current integrator (bevin-events, HostelConnect's v2 backend, Academicx) is Node.js on the server side, so this one client covers the fleet as-is.
Install
npm install @bevingh/conduit-client@bevingh/payments comes in as a dependency automatically (used only for its HMAC verification helper — no axios peer dependency is triggered by that).
Public API
import { createConduitClient } from '@bevingh/conduit-client';
const conduit = createConduitClient({
baseUrl: process.env.CONDUIT_BASE_URL!, // e.g. https://conduit.yourcompany.com
apiKey: process.env.CONDUIT_API_KEY!, // sk_test_... or sk_live_...
// optional: fetchImpl — inject for tests or a non-global-fetch runtime
});
// Mobile money charge (async — listen for the payment.completed webhook, or poll getStatus)
const charge = await conduit.payments.initiateCharge(
{ phone: '0241234567', network: 'MTN', amount: 50, currency: 'GHS', paymentType: 'school-fees' },
{ idempotencyKey: crypto.randomUUID() }, // optional — 24h dedup window, scoped per product
);
// charge.status is "processing", or rarely "otp_required" — see below
if (charge.status === 'otp_required') {
await conduit.payments.submitOtp(charge.ref, otpFromCustomer);
}
// Card checkout — returns a hosted Paystack URL to redirect the customer to
const checkout = await conduit.payments.initiateCheckout({
amount: 100, currency: 'GHS', email: '[email protected]',
callbackUrl: 'https://yourapp.com/payment/callback',
});
const status = await conduit.payments.getStatus(charge.ref);
const refund = await conduit.payments.requestRefund(charge.ref, 'Customer requested cancellation');
// queues for admin review — does not refund immediately
// Pay-by-Code (USSD, no smartphone needed)
const code = await conduit.codes.generate({ amount: 20, currency: 'GHS', description: 'School lunch top-up' });
const state = await conduit.codes.lookup(code.code);
await conduit.codes.cancel(code.code);
// Transactions
const page = await conduit.transactions.list({ status: 'completed', page: 1, limit: 20 });
const tx = await conduit.transactions.getByRef(charge.ref);
const disbursement = await conduit.transactions.getDisbursement(charge.ref);| Export | Role |
|---|---|
| createConduitClient(config) | Returns { payments, codes, transactions, webhooks } |
| ConduitApiError | Thrown on any non-2xx response — status, message, optional code (e.g. INVALID_OTP), optional correlationId, raw body |
| verifyConduitWebhookSignature | Standalone HMAC-SHA256 verification for Conduit's outbound webhook (see below) |
Every resource method's parameter and return shape mirrors docs/api-reference.md field-for-field (see src/types.ts) — amount/dates as documented, no implicit unit conversion (Conduit's product-facing API is major units throughout, unlike @bevingh/money's integer-pesewa convention, which doesn't apply here).
Idempotency
initiateCharge, initiateCheckout, and codes.generate — the three POST routes Conduit itself supports replay-dedup on — take an optional second argument { idempotencyKey }, sent as the Idempotency-Key header. Same request within 24h (scoped per product on Conduit's side) replays the original response instead of creating a second charge. Not supplying one is fine; it's the caller's choice to opt in per the underlying route's support, not a blanket client-side retry policy.
Errors
import { ConduitApiError } from '@bevingh/conduit-client';
try {
await conduit.payments.submitOtp(ref, otpCode);
} catch (err) {
if (err instanceof ConduitApiError) {
// err.status, err.message (= response body's "error"), err.code (e.g. "INVALID_OTP"), err.correlationId
}
}Matches docs/api-reference.md's error envelope ({ "error": "<message>" }, occasionally code/correlationId) exactly — this client does not invent its own error shape or retry policy on top of it.
Verifying Conduit's outbound webhook
Conduit signs its outbound payment.* events with X-Conduit-Signature — HMAC-SHA256 of the raw JSON body, keyed by the product's WEBHOOK_SECRET (docs/api-reference.md "Admin: Webhook Deliveries"). This is a different algorithm from the SHA-512 Conduit itself uses to verify inbound Paystack webhooks — do not reuse a sk_test_/sk_live_ API key or a Paystack secret here.
import { verifyConduitWebhookSignature } from '@bevingh/conduit-client';
// rawBody MUST be the original wire bytes (Buffer, or the exact pre-parse string) —
// re-serializing via JSON.stringify(req.body) changes the bytes and breaks the HMAC.
const valid = verifyConduitWebhookSignature(req.rawBody, req.headers['x-conduit-signature'], process.env.CONDUIT_WEBHOOK_SECRET!);This is a thin wrapper over @bevingh/payments' verifyWebhookSignature(rawBody, signature, secret, { algorithm: 'sha256' }) — the crypto lives in that package, not duplicated here (@bevingh/payments' own doc-comment already names sha256 as the Conduit-outbound case).
Express adapter (@bevingh/conduit-client/adapters/express)
import { createConduitWebhookMiddleware } from '@bevingh/conduit-client/adapters/express';
// Capture the raw body BEFORE this middleware — same requirement as
// @bevingh/payments' createPaystackWebhookMiddleware. No JSON.stringify(req.body)
// fallback exists; a missing rawBody fails loud via next(TypeError), not silently.
app.use('/conduit-webhook', express.json({
verify: (req, _res, buf) => { (req as any).rawBody = buf; },
}));
app.post('/conduit-webhook', createConduitWebhookMiddleware({
secret: process.env.CONDUIT_WEBHOOK_SECRET!,
handle: async (payload) => {
// payload: ConduitWebhookPayload — only reachable after signature verification succeeds
},
}), handler);Responds 401 on a bad signature, calls next(TypeError) if req.rawBody was never captured, next(AppError) if secret is falsy — verification is structural, not optional, same principle as @bevingh/payments' inbound contract.
What this package does not do
- Not the admin/operator API. Product/recipient/subaccount/markup setup, disbursement retries, refund approval, finance/ledger queries — all
/admin/*, out of scope. That flow isconduit-admin-app(and, as a fallback,conduit/scripts/cli.js's "Onboard new product"). - Not the USSD router integration.
/api/v1/ussdis called by the Arkesel USSD router proxy only, authenticated withx-ussd-router/x-ussd-secret, never by a product's own backend. - No retry/backoff policy. A failed request throws
ConduitApiError(or letsfetch's own network error propagate) — the caller decides whether and how to retry. This mirrors@bevingh/payments' driver style (inject behavior, don't hide it). - No built-in amount/currency conversion. Conduit's product-facing API takes and returns major units (e.g.
50.00GHS) throughout — this client passes them through as-is.
Tests
npm run test -w @bevingh/conduit-clientResource methods verified against a mocked fetchImpl (correct method/path/headers/body per docs/api-reference.md, idempotency header pass-through, query-string building with undefined params omitted, ConduitApiError field mapping on non-2xx). Webhook verification verified against real node:crypto HMAC-SHA256 signatures (valid, tampered, wrong secret, wrong algorithm, string vs. Buffer raw body) and the Express middleware against fake req/res/next (valid → 200 + handled payload, invalid signature → 401 + handle never called, missing rawBody → next(TypeError), case-insensitive header lookup). Not verified against a live Conduit deployment or a live webhook delivery — recommend a sk_test_* smoke test (one charge, one status poll, one webhook round-trip) before trusting this in a real integration.
