npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

Readme

@yukwaindustries/sph-sdk

npm

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-sdk

Requires 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 MissingIdempotencyKeyError

It 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, exact

The 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, resetPassword
  • sp.account — get, update, listKeys, createKey, revokeKey, submitKyc
  • sp.payments — collect, disburse, status, waitForSettlement
  • sp.transactions — list, get, refund
  • sp.paymentLinks — create, list, get, deactivate
  • sp.checkout — get, pay, status (public; no keys needed)
  • sp.settlements — create, list
  • sp.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 abort

SPHEAVY_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 example

Regenerate 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/ first

files 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

License

MIT © Yukwa Industries