@yukwaindustries/sph-sdk
v0.3.0
Published
Official TypeScript/JavaScript client for the SP-Heavy payments API — MTN MoMo and Orange Money collections, payouts, payment links and signed webhooks for Cameroon and the CEMAC zone.
Maintainers
Readme
@yukwaindustries/sph-sdk
Official TypeScript/JavaScript client for the SP-Heavy payments API — MTN MoMo and Orange Money collections, payouts, payment links and signed webhooks for Cameroon and the CEMAC zone.
Types are generated from the OpenAPI spec (openapi-typescript) over an
openapi-fetch runtime, wrapped in an ergonomic, namespaced client.
Install
npm install @yukwaindustries/sph-sdkRequires Node 18+ (it uses the built-in fetch; pass your own via the
fetch option on older runtimes).
New to SP-Heavy? The step-by-step integration guide walks through the whole flow — sign-up, first test payment, webhooks, going live — with diagrams and copy-paste code in four languages.
Configure
Nothing is required if these are set in your environment — which is exactly what
the .env file the dashboard gives you contains:
SPHEAVY_BASE_URL=https://api.spheavy.com # optional; this is also the default
SPHEAVY_PUBLIC_KEY=pk_test_…
SPHEAVY_SECRET_KEY=sk_test_…import { SPHeavyClient } from '@yukwaindustries/sph-sdk';
const sp = new SPHeavyClient(); // reads the env vars above
// …or pass them explicitly:
// new SPHeavyClient({ baseUrl: 'http://127.0.0.1:3001', publicKey, secretKey })baseUrl accepts either spelling — a trailing /v1 is trimmed, because every
path already includes it and /v1/v1/... would 404 in a way that looks like a
missing endpoint.
Server-side only. The secret key authorises money movement, so it must never reach a browser bundle, a mobile app, or a public repository.
Usage
import {
SPHeavyClient, SPHeavyError, SPHeavyUnknownOutcomeError, SPHeavyStillPendingError,
toMinorUnits,
} from '@yukwaindustries/sph-sdk';
const sp = new SPHeavyClient({ strictIdempotency: true });
try {
// 1. Ask the payer to approve. Returns immediately as PENDING.
const tx = await sp.payments.collect(
{
amount: toMinorUnits(1500, 'XAF'), // 1500 FCFA — no guessing at the scale
currency: 'XAF',
provider: 'MTN',
phoneNumber: '237670000000',
},
`order-${order.id}`, // your id, so a retry is the same payment
);
// 2. Wait for the payer to approve on their handset.
const settled = await sp.payments.waitForSettlement(tx.reference, { timeoutMs: 120_000 });
if (settled.status === 'SUCCESSFUL') await fulfilOrder(settled.reference, settled.net);
else await showDecline(settled.providerStatusMessage);
} catch (err) {
if (err instanceof SPHeavyStillPendingError) {
// Not approved yet. Not a failure — leave it open, a webhook will finish it.
await markAwaitingApproval(order.id, err.reference);
} else if (err instanceof SPHeavyUnknownOutcomeError) {
// MAY have happened. Never refund here; reconcile with the same key.
await markPending(order.id, err.idempotencyKey);
} else if (err instanceof SPHeavyError) {
await markFailed(order.id, err.code); // definitely did not happen
} else throw err;
}
const { items, pagination } = await sp.transactions.list({ status: 'SUCCESSFUL', limit: 20 });
await sp.transactions.refund('COL_…');
const balances = await sp.balance();Idempotency
Replaying a request with the same key returns the original result instead of moving money again — the only thing that makes a retry after a timeout safe. The SDK always sends one. Pass your own, positionally or in the options:
await sp.payments.collect(body, `order-${order.id}`);
await sp.payments.disburse(body, undefined, { idempotencyKey: `payout-${id}` });The generated fallback only protects a retry inside one process. A job runner, a restarted worker or a reconciliation sweep each arrive with a fresh key and move the money again. Only your system has an identifier that survives a restart, so in a production service turn on strict mode and let the SDK refuse the unsafe call:
const sp = new SPHeavyClient({ strictIdempotency: true });
await sp.payments.collect(body); // throws MissingIdempotencyKeyErrorIt is off by default so existing code keeps working. refund derives its key
from the transaction reference and needs nothing from you.
Money: minor units, exactly
The API speaks minor units — the smallest indivisible amount. XAF and XOF
have no subdivision, so 1500 is 1500 FCFA. USD and EUR have cents, so 1500
is $15.00. Get the scale wrong and the payment is out by 100×.
import { toMinorUnits, toMajorUnits, formatAmount, minorUnits } from '@yukwaindustries/sph-sdk';
toMinorUnits(1500, 'XAF') // 1500 — francs have no subdivision
toMinorUnits('15.00', 'USD') // 1500 — cents
toMinorUnits(8.07, 'USD') // 807 — exact; not 806
toMinorUnits('15.005', 'USD') // throws — half a cent is not chargeable
toMajorUnits(tx.net, tx.currency) // 1477
formatAmount(tx.net, tx.currency, 'fr-CM') // "1 477 FCFA"Conversion is string-based, not × 100 — floats cannot hold decimal money
(8.07 * 100 is 806.9999999999999). An amount finer than the currency allows
is rejected rather than rounded, so rounding stays a decision you make.
Reading money off a transaction
Requests take a number; responses return strings in minor units, so a
large balance can never lose precision to a float. Use minorUnits() to do
arithmetic on them — it returns a bigint:
const net = minorUnits(tx.amount) - minorUnits(tx.fee); // 1477n, exactThe trap it avoids: + on the raw strings concatenates ('1500' + '23' is
'150023'), and past 2⁵³ a Number cannot represent a zero-scale balance at
all. Each transaction also carries amountDisplay, feeDisplay and
netDisplay — the same values in major units, for showing to a person.
Waiting for a payment to settle
A collection is asynchronous: the payer approves on their handset, so collect
returns PENDING and the answer arrives later. Rather than writing the polling
loop yourself:
const tx = await sp.payments.waitForSettlement(reference, { timeoutMs: 120_000 });
if (tx.status === 'SUCCESSFUL') await fulfilOrder(tx.reference, tx.net);Returns the transaction in whichever terminal state it reached — it does not
throw on FAILED, because a declined payment is an answer. If the deadline
passes while it is still pending it throws SPHeavyStillPendingError, which
never guesses an outcome: the payer simply may not have approved yet. That class
extends SPHeavyUnknownOutcomeError, so a handler that already treats
indeterminate results correctly needs no new branch.
Polling is capped at one check per 250 ms so a loop cannot trip the rate limiter, and only the final check asks the operator directly. A webhook is still the better mechanism where you can receive one — this is for the request-scoped case where you need an answer before responding.
Keys can also be issued programmatically over a dashboard session:
await sp.auth.register({ businessName: 'Acme', email: '[email protected]', password: '…' });
const creds = await sp.account.createKey('sandbox'); // secret shown once
sp.setApiKeys(creds.publicKey, creds.secretKey);Surface
sp.auth—register,login,twoFactor,verifyEmail,forgotPassword,resetPasswordsp.account—get,update,listKeys,createKey,revokeKey,submitKycsp.payments—collect,disburse,status,waitForSettlementsp.transactions—list,get,refundsp.paymentLinks—create,list,get,deactivatesp.checkout—get,pay,status(public; no keys needed)sp.settlements—create,listsp.balance()
Every method returns the unwrapped data payload and throws on failure.
.requestId matches the X-Request-Id header — quote it in support requests.
Errors: a failure is not the same as an unknown outcome
This is the distinction that decides whether you refund someone twice.
When the API rejects a request, nothing happened — no money moved, and you can fail the intent. When a request times out, or the connection drops, or the server answers 5xx, you know only that you did not get an answer. The payment may have been taken; a payout may already be on its way to the recipient. Treating that as a failure and paying again sends the money twice.
So every error carries outcomeKnown:
import {
SPHeavyError,
SPHeavyUnknownOutcomeError,
} from '@yukwaindustries/sph-sdk';
try {
await sp.payments.disburse(body, `payout-${id}`);
await markPaid(id);
} catch (err) {
if (err instanceof SPHeavyUnknownOutcomeError) {
// MAY have happened. Do not refund, do not retry blind.
// Leave it open and reconcile — replaying with the same idempotency key
// returns the original result rather than paying again.
await markPending(id, err.idempotencyKey);
} else if (err instanceof SPHeavyError) {
await markFailed(id, err.code); // definitely did not happen
} else {
throw err; // a bug in your own handler
}
}| Class | outcomeKnown | When |
| ----- | -------------- | ---- |
| SPHeavyAPIError | true | 4xx — validation, conflict, not found |
| SPHeavyAuthError | true | 401 / 403 — bad key, revoked key, KYC not approved |
| SPHeavyRateLimitError | depends | 429 — carries retryAfterSeconds; always safe to replay |
| SPHeavyUnknownOutcomeError | false | timeout, network failure, abort, or 5xx |
Where the classification comes from
The API publishes its own verdict on every error it handles, as outcome
(failed | unknown) and retryable, and the client uses it — exposed as
err.outcome and err.retryable. The server knows whether a handler ran; a
status code only hints at it.
The table above is the fallback, for responses that carry no envelope: a proxy
502, a load-balancer 503, anything that never reached the API. Those stay
indeterminate, which is why SPHeavyRateLimitError "depends" — a 429 from
SP-Heavy's own limiter says outcome: "failed" and nothing ran, while a 429
with no envelope could have come from anywhere and is treated as unknown.
SPHeavyError is the base class, so a single catch (e) { if (e instanceof
SPHeavyError) … } still catches everything. Every error also carries method,
path and — for money-moving calls — the idempotencyKey that was sent, which
is the handle you need to resolve an unknown outcome.
SPHeavyUnknownOutcomeError.reason narrows it further: 'timeout',
'network', 'aborted' or 'server_error'.
Resolving an unknown outcome
Two ways, both safe:
// 1. Replay with the same key — returns the original result if it did land.
await sp.payments.disburse(body, err.idempotencyKey);
// 2. Or ask what actually happened, going to the operator if needed.
const tx = await sp.payments.status(reference, true);Timeouts
Requests time out after 30 seconds by default, raising
SPHeavyUnknownOutcomeError with reason: 'timeout'. Without a deadline a
stalled connection hangs until the OS gives up, which can be minutes — in a
checkout that is a customer watching a spinner.
const sp = new SPHeavyClient({ timeoutMs: 10_000 }); // client-wide
await sp.payments.collect(body, key, { timeoutMs: 5_000 }); // this call only
await sp.balance({ signal: controller.signal }); // your own abortSPHEAVY_TIMEOUT_MS sets it from the environment. 0 disables it. A
client-wide signal cancels every in-flight request — useful on shutdown.
Webhooks
SP-Heavy notifies your callbackUrl whenever a transaction reaches a terminal
state. Verify every webhook before acting on it — these events move money in
your books, and an unverified endpoint will happily accept forged ones.
Pass headers and the helper reads the signature, timestamp and event id
for you — the id comes back on the event, which is what you deduplicate on:
import express from 'express';
import {
constructWebhookEvent,
WebhookVerificationError,
type TransactionEventData,
} from '@yukwaindustries/sph-sdk';
app.post('/webhooks/spheavy',
// The signature covers the RAW bytes. If you let a JSON parser touch the body
// and re-serialise it, key order changes and verification always fails.
express.raw({ type: 'application/json' }),
async (req, res) => {
let event;
try {
event = constructWebhookEvent<TransactionEventData>({
payload: req.body, // raw Buffer
headers: req.headers, // signature, timestamp and id together
secret: process.env.SPHEAVY_WEBHOOK_SECRET!,
});
} catch (err) {
// Forged, stale, or malformed — every rejection is this one type.
if (err instanceof WebhookVerificationError) return res.sendStatus(400);
throw err;
}
// Delivery is at-least-once. This is the guard against a redelivery
// crediting the same wallet twice.
if (await alreadyHandled(event.id)) return res.sendStatus(200);
if (event.event === 'transaction.successful') {
// `net` is what actually landed in your wallet (amount − fee).
// Reconciling on `amount` is off by the fee on every collection.
await fulfilOrder(event.data.reference, event.data.net);
}
await remember(event.id);
res.sendStatus(200); // any 2xx stops the retries
});headers accepts a Fetch Headers instance, Express's req.headers, or any
plain object — matched case-insensitively. You can still pass signature,
timestamp and id individually if you prefer.
Or skip the boilerplate entirely
// Express
import { expressWebhook } from '@yukwaindustries/sph-sdk';
app.post(
'/webhooks/spheavy',
express.raw({ type: 'application/json' }),
expressWebhook(async (event) => {
if (await alreadyHandled(event.id)) return;
if (event.event === 'transaction.successful') {
await fulfilOrder(event.data.reference, event.data.net);
}
await remember(event.id);
}),
);// Next.js App Router — app/api/webhooks/spheavy/route.ts
import { fetchWebhook } from '@yukwaindustries/sph-sdk';
export const POST = fetchWebhook(async (event) => {
if (await alreadyHandled(event.id)) return;
if (event.event === 'transaction.successful') {
await fulfilOrder(event.data.reference, event.data.net);
}
await remember(event.id);
});Both read SPHEAVY_WEBHOOK_SECRET by default, reply 400 to anything that
fails verification, and 200 once your handler resolves. If your handler
throws, they reply non-2xx on purpose, so SP-Heavy retries rather than
treating a bug in your fulfilment as success.
expressWebhook still needs express.raw() mounted ahead of it — it cannot add
that for you, and express.json() would already have destroyed the bytes the
signature covers. If the body arrives parsed it says exactly that, instead of
reporting a signature mismatch. fetchWebhook has no such trap: it reads the
raw body itself.
Every rejection is a WebhookVerificationError — including a body that is
not valid JSON. Nothing else escapes, so the single instanceof check above
covers the whole path. (A bare SyntaxError slipping through would become a
500, and since SP-Heavy retries anything that is not a 2xx, one malformed body
would turn into a redelivery loop.)
Headers on every delivery
| Header | Meaning |
| ------ | ------- |
| X-SPHeavy-Signature | HMAC-SHA256 of <timestamp>.<raw body>, hex |
| X-SPHeavy-Timestamp | Unix epoch ms, part of the signed string |
| X-SPHeavy-Id | Stable event id — deduplicate on this; returned as event.id |
| X-SPHeavy-Event | e.g. transaction.successful |
Delivery is at-least-once with exponential backoff (6 attempts). The same
event can arrive twice — a retried delivery, or a provider callback racing a
reconciliation sweep — so make your handler idempotent on event.id.
Anything outside 2xx is treated as a failure and retried.
Your signing secret is on the dashboard under Account, and can be rotated there.
Developing this package
From a checkout of the repository:
npm install
npm run generate # regenerate src/schema.ts from ../backend/openapi/openapi.json
npm run build # compile src/ → dist/
npm run typecheck # no emit
# Run the live example against a local backend on :3001
SPHEAVY_BASE_URL=http://127.0.0.1:3001 npm run exampleRegenerate openapi.json from the backend whenever the API changes:
cd ../backend && npm run openapi:json.
Releasing
npm run release:check # build, then list exactly what would ship
npm version patch # or minor / major
npm publish # prepublishOnly rebuilds dist/ firstfiles in package.json is the allowlist; .npmignore is a second guard so a
missing one can never fall back to .gitignore (which ignores dist/, and
would publish a package containing no code).
Support
- Docs and playground — spheavy.com/docs
- Issues — github.com/X30psycho/spheavy-sdk/issues
- Email — [email protected]
License
MIT © Yukwa Industries
