Accepting payments with SP-Heavy

📖 [Read this on spheavy.com](https://spheavy.com/docs/guide) — the same document, rendered with diagrams and a step-by-step sidebar. No account needed to read it.

A step-by-step guide for adding mobile-money payments (MTN MoMo, Orange Money) to your website or app. It assumes you have never used a payment API before.

Everything here runs against the free sandbox first — no real money, no paperwork, no waiting. You only submit business documents when you are ready to charge real customers.

Time to first test payment: about 15 minutes.


1. What SP-Heavy actually does

Your customers in Cameroon pay with MTN Mobile Money and Orange Money, not cards. Each of those operators has its own API, its own credentials, its own rules. SP-Heavy sits in the middle so you write your integration once.

Your servershop, invoice, appSP-Heavyone APIMTN / Orangethe operatorCustomertheir handsetcollect()forwardspromptPIN ✓resultwebhook: transaction.successfulYou never see or handle the customer's PIN
You call one endpoint. The customer authorises on their own handset, and the confirmed result comes back to you as a signed webhook.

The important thing to understand: you never touch the customer's PIN. You ask SP-Heavy to charge a phone number; the customer approves it on their own handset. Your server just waits to be told the result.

Two directions of money:

What it doesWhen you use it
CollectPulls money from a customer to your walletCheckout, invoices, subscriptions
DisburseSends money from your wallet to someonePayouts, refunds by hand, supplier payments

Money you collect sits in your SP-Heavy wallet. You later request a settlement to move it to your bank account.


2. Get your test keys

  1. Go to [spheavy.com](https://spheavy.com) and create an account (business name, email, password).
  2. Open the API Keys tab in the sidebar.
  3. Leave the toggle on Sandbox and click Generate key.

You get two strings:

KeyLooks likeWhat it is
Public keypk_test_a1b2c3…Identifies your business. Not secret.
Secret keysk_test_x9y8z7…Proves it is you. Anyone holding it can move your money.

### ⚠️ The secret key is shown exactly once SP-Heavy stores only a one-way hash of it, so it genuinely cannot be shown to you again — not even by support. Click Download .env on that screen before you navigate away. If you lose it, revoke the key and generate a new one; that is a normal, safe thing to do.

pk_test_ / sk_test_ keys are sandbox keys. They move no real money and cannot reach the real MTN or Orange networks. Live keys look like pk_live_ / sk_live_ and only exist after going live.


3. Store your keys safely

The Download .env button gives you a file that looks like this. Put it in your project root:

# .env
SPHEAVY_BASE_URL=https://api.spheavy.com
SPHEAVY_PUBLIC_KEY=pk_test_a1b2c3…
SPHEAVY_SECRET_KEY=sk_test_x9y8z7…

Then immediately make sure it can never be committed:

echo ".env" >> .gitignore

The one rule that matters

Browseryour checkout pageYour backendholds sk_live_…SP-Heavy API"pay for order 1024"collect() + secret keyNever call SP-Heavy from the browserthe secret key would ship to every visitor
The secret key lives on your server and nowhere else. Your front-end talks to your own backend, which talks to SP-Heavy.

All SP-Heavy API calls happen from your server, never from the browser. If your checkout page is a React app, it calls your backend, and your backend calls SP-Heavy. A secret key shipped to a browser is readable by every visitor and every browser extension they have installed.

If you don't want to run a backend at all, skip to payment links — those need no keys on the customer's side.


4. Install the SDK

Each SDK reads the environment variables from step 3 automatically, so there is nothing else to configure.

Node.js / TypeScript — on npm, requires Node 18+:

npm install @yukwaindustries/sph-sdk

### 📦 Python and PHP are not on a registry yet pip install sp-heavy and composer require sp-heavy/sdk will not work today — those names are reserved for a later release. Install from a checkout of this repository for now.

Python

pip install /path/to/spheavy/sph-sdk-python

PHP — add a path repository to your composer.json:

{
  "repositories": [{ "type": "path", "url": "/path/to/spheavy/sph-sdk-php" }],
  "require": { "sp-heavy/sdk": "*" }
}
composer update sp-heavy/sdk

Anything else — you do not need an SDK at all. The API is plain HTTPS and JSON: every request carries two headers, X-Public-Key and X-Secret-Key, and a JSON body. See the curl example. You will only need to copy the ~20 lines of webhook verification.


5. Take your first test payment

237670000000 is a magic test number that always succeeds in the sandbox.

Node.js / TypeScript

import { SPHeavyClient } from '@yukwaindustries/sph-sdk';

const sp = new SPHeavyClient();          // picks up SPHEAVY_* from the environment

const tx = await sp.payments.collect({
  amount: 1500,                          // ⚠️ see the note on amounts below
  currency: 'XAF',
  provider: 'MTN',                       // or 'ORANGE'
  phoneNumber: '237670000000',
  description: 'Order #1024',
});

console.log(tx.reference, tx.status);    // → COL_8f2a… PENDING

Python

from spheavy import SPHeavyClient

sp = SPHeavyClient()                     # picks up SPHEAVY_* from the environment

tx = sp.payments.collect(
    amount=1500,
    currency="XAF",
    provider="MTN",                      # or "ORANGE"
    phone_number="237670000000",
    description="Order #1024",
)

print(tx["reference"], tx["status"])     # → COL_8f2a… PENDING

PHP

<?php
require 'vendor/autoload.php';

$sp = new \SpHeavy\Client();              // picks up SPHEAVY_* from the environment

$tx = $sp->payments->collect([
    'amount'      => 1500,
    'currency'    => 'XAF',
    'provider'    => 'MTN',               // or 'ORANGE'
    'phoneNumber' => '237670000000',
    'description' => 'Order #1024',
]);

echo $tx['reference'] . ' ' . $tx['status'];   // → COL_8f2a… PENDING

curl (any language)

curl -X POST https://api.spheavy.com/v1/payments/collect \
  -H "X-Public-Key: $SPHEAVY_PUBLIC_KEY" \
  -H "X-Secret-Key: $SPHEAVY_SECRET_KEY" \
  -H "Idempotency-Key: order-1024" \
  -H "Content-Type: application/json" \
  -d '{"amount":1500,"currency":"XAF","provider":"MTN","phoneNumber":"237670000000"}'

### ⚠️ Amounts are whole numbers, in the smallest unit XAF and XOF have no decimal places, so 1500 means 1500 FCFA. Never send 1500.00 or "1500" — send the integer 1500. Sending 15.5 is rejected. (For a currency with cents, such as EUR, 1500 would mean €15.00.)

Why Idempotency-Key matters

If your request times out, you cannot tell "it never arrived" from "it arrived but the reply was lost". Retrying blindly charges your customer twice.

An idempotency key fixes that: reuse the same key and SP-Heavy returns the original result instead of charging again. The SDKs always send one, so you get this protection for free. Pass your own — an order id is ideal — when the retry might come from a different process, such as a background job:

await sp.payments.collect({ /* … */ }, `order-${order.id}`);

6. Understand what came back

The response is 202 Accepted with status `PENDING`. That is correct and expected: the customer has not touched their phone yet.

PENDINGPROCESSINGSUCCESSFULFAILEDREVERSEDacceptedPIN ✓declined · no funds · expiredrefundcollect() returns herefulfil the order here
collect() returns at the left of this diagram, not the right. The status you act on arrives later.
StatusMeaningWhat you should do
PENDINGWaiting for the customer's PINShow "waiting for approval"
PROCESSINGOperator is moving the moneyKeep waiting
SUCCESSFULMoney receivedFulfil the order
FAILEDDeclined / no funds / expiredShow the reason, offer a retry
REVERSEDRefunded afterwardsUndo the fulfilment

🚫 The mistake everyone makes

Do not fulfil the order when `collect()` returns. It returns PENDING — the customer has paid nothing yet. Wait for SUCCESSFUL, which arrives via a webhook.

Fees: reconcile on net, not amount

Every successful collection is charged a platform fee. The transaction carries three numbers:

FieldMeaning
amountWhat the customer paid — 1500
feeWhat SP-Heavy charged — 22 at the default 1.5%
netWhat actually reached your wallet — 1478

(Your rate is shown on your account; the default is 150 basis points.)

Reconciling your books against amount will be wrong by the fee on every single payment. Use net.


7. Get told when the payment finishes (webhooks)

A webhook is SP-Heavy calling your server when something happens. This is how you learn a payment succeeded.

Your serverSP-HeavyCustomercollect(1500 XAF)202 · PENDINGShow "waiting for approval"DO NOT fulfil yetprompt on the handsetenters PIN ✓POST /webhooks/spheavytransaction.successfulVerify the signature,THEN fulfil the order200 OK · stops the retries
Time runs downward. The gap between the pending reply and the webhook is the customer reaching for their phone — it can be seconds or minutes.

Set your webhook URL in the dashboard under Account → callback URL, and copy the webhook secret from the same screen into your .env:

SPHEAVY_WEBHOOK_SECRET=whsec_…

Verify every webhook

Your webhook URL is public. Anyone can find it and POST fake "payment successful" events to it. The signature is what proves a message really came from SP-Heavy — an unverified endpoint will happily ship goods for free.

Node.js / Express

import express from 'express';
import { constructWebhookEvent, type TransactionEventData } from '@yukwaindustries/sph-sdk';

app.post('/webhooks/spheavy',
  // ⚠️ express.raw, NOT express.json — see the warning below
  express.raw({ type: 'application/json' }),
  (req, res) => {
    let event;
    try {
      event = constructWebhookEvent<TransactionEventData>({
        payload: req.body,                              // raw Buffer
        signature: req.header('X-SPHeavy-Signature')!,
        timestamp: req.header('X-SPHeavy-Timestamp')!,
        secret: process.env.SPHEAVY_WEBHOOK_SECRET!,
      });
    } catch {
      return res.sendStatus(400);                       // forged or too old
    }

    // Delivery is at-least-once: the same event CAN arrive twice.
    // X-SPHeavy-Id is stable — skip it if you have seen it before.
    if (alreadyHandled(req.header('X-SPHeavy-Id')!)) return res.sendStatus(200);

    if (event.event === 'transaction.successful') {
      fulfilOrder(event.data.reference, event.data.net);   // `net`, not `amount`
    }

    res.sendStatus(200);        // any 2xx stops the retries
  });

Python / Flask

from spheavy import construct_webhook_event, WebhookVerificationError

@app.post("/webhooks/spheavy")
def spheavy_webhook():
    try:
        event = construct_webhook_event(
            payload=request.get_data(),                  # raw bytes
            signature=request.headers["X-SPHeavy-Signature"],
            timestamp=request.headers["X-SPHeavy-Timestamp"],
            secret=os.environ["SPHEAVY_WEBHOOK_SECRET"],
        )
    except WebhookVerificationError:
        return "", 400

    if already_handled(request.headers["X-SPHeavy-Id"]):
        return "", 200

    if event["event"] == "transaction.successful":
        fulfil_order(event["data"]["reference"], event["data"]["net"])

    return "", 200

PHP

<?php
$payload = file_get_contents('php://input');            // raw body

try {
    $event = \SpHeavy\Webhooks::constructEvent(
        $payload,
        $_SERVER['HTTP_X_SPHEAVY_SIGNATURE'] ?? '',
        $_SERVER['HTTP_X_SPHEAVY_TIMESTAMP'] ?? '',
        getenv('SPHEAVY_WEBHOOK_SECRET')
    );
} catch (\SpHeavy\SpHeavyException $e) {
    http_response_code(400);
    exit;
}

if (alreadyHandled($_SERVER['HTTP_X_SPHEAVY_ID'] ?? '')) { http_response_code(200); exit; }

if ($event['event'] === 'transaction.successful') {
    fulfilOrder($event['data']['reference'], $event['data']['net']);
}

http_response_code(200);

### ⚠️ Use the RAW body The signature is computed over the exact bytes SP-Heavy sent. If your framework parses the JSON and you re-serialise it, key order and spacing change and verification will fail every time. Use express.raw(…), request.get_data(), file_get_contents('php://input') — never the parsed object. This is the single most common integration bug.

What arrives on every delivery

HeaderMeaning
X-SPHeavy-SignatureHMAC-SHA256 of <timestamp>.<raw body>, hex
X-SPHeavy-TimestampEpoch milliseconds, part of the signed string
X-SPHeavy-IdStable event id — deduplicate on this
X-SPHeavy-Eventtransaction.successful, transaction.failed, transaction.reversed

Delivery is at-least-once, retried up to 6 times with exponential backoff starting at 30 seconds. Return any 2xx to stop the retries. Anything else — including a timeout — counts as a failure and will be retried, so the same event can genuinely arrive twice. Make your handler safe to run twice.

Developing on your laptop

SP-Heavy cannot reach localhost. Use a tunnel:

npx localtunnel --port 3000     # or: ngrok http 3000

Then paste the public HTTPS URL into Account → callback URL.

If a webhook never arrives

Webhooks are a convenience, not the source of truth. You can always ask directly, and this is the right fallback for an order stuck as pending:

const tx = await sp.payments.status('COL_8f2a…', true);   // true = ask the operator now

You can also inspect delivery attempts over the API with GET /v1/account/webhooks, and ask for one to be redelivered with POST /v1/account/webhooks/:id/resend (both use your dashboard session, not your API keys).


8. Test every outcome

A real integration has to handle failure. In the sandbox, the last four digits of the phone number decide what happens — any prefix works, MTN or Orange:

Phone numberResultMessage you get back
237670000000 (or any other)✅ SUCCESSFULSUCCESSFUL
237670000001❌ FAILEDNOT_ENOUGH_FUNDS
237670000002❌ FAILEDAPPROVAL_REJECTED — customer declined
237670000003❌ FAILEDEXPIRED — customer ignored the prompt
237670000004❌ Rejected instantlyPAYER_NOT_FOUND — wrong number
237670000009⏳ PENDING foreversimulates an operator that never replies

A sandbox collection is accepted as PENDING and resolves about 6 seconds later, mimicking the real async callback. In a hurry? Poll with ?sync=true for an immediate answer.

Test all six before you go live. …0009 in particular tells you whether your UI copes with a payment that simply never resolves.

You can also run these from the live playground at spheavy.com/docs — it fires real sandbox requests and shows you the exact HTTP request and response.


If you don't want to build a checkout — or don't have a backend at all — create a payment link. SP-Heavy hosts the page; you just share the URL.

const link = await sp.paymentLinks.create({
  amount: 5000,
  currency: 'XAF',
  description: 'Invoice #42',
  successUrl: 'https://yourshop.com/thanks',
});

console.log(link.checkoutUrl);   // https://spheavy.com/checkout/plink_…

Send that URL by WhatsApp, SMS or email, or make it your "Pay now" button. The customer picks MTN or Orange, enters their number, and approves on their phone.

You can also create links from the dashboard's Payment Links tab with no code at all.

The amount is fixed on the server, so a customer cannot edit the page to pay less. You still receive the same transaction.successful webhook.


10. Going live

Sandbox needs nothing. Real money needs identity checks — this is a legal requirement for payment services, not a formality.

You do not need a registered company. There are two ways to be verified, and both unlock live payments:

Not registered yetgovernment ID + selfieverifies you, the personRegistered businessregistration numberverifies the companyApprovedlive payments enabledGenerate live keysswap .env, redeployBoth routes reach the same approval — nothing in your code changes
The individual route exists so a seller can start taking payment before they have registered a company. It is a starting position, not a ceiling.

If you have not registered a business yet

Choose "I'm not registered yet" in Account → Compliance (KYC). You verify yourself, not a company:

What you provideNotes
Full name as printed on your IDMust match the document exactly
Date of birthYou must be 18 or over
Document typeNational ID card or passport
ID / passport number
Phone number
Photo of the front of the IDAll four corners visible, text readable
Photo of the back of the ID
Selfie holding the front of your IDYour face and the ID both clearly visible in one photo

You take the photos with your phone — there is nothing to upload anywhere first. The dashboard shrinks each one in your browser before sending it, so this works on a slow connection; a 4 MB camera photo typically becomes about 200 KB.

Submitting over the API instead? The three photos go inline as base64 data URIs (data:image/jpeg;base64,…) in the idFront, idBack and selfieWithId fields. JPEG, PNG or WebP, 512 KB each once decoded — downscale to about 1600px on the long edge first.

The selfie is what ties the document to you — without it, a stolen ID card would be enough to open an account in someone else's name.

Once approved you can take live payments under your own name. When you are ready to register a company, SP-Heavy will help you through it and re-verify you on the business tier — you keep your account, keys and history.

If you have a registered business

Choose "Registered business" and provide the registered legal name, the owner or director's name, and your business registration number.

Then, for either route

  1. Wait for approval. Status goes NOT_SUBMITTED → PENDING → APPROVED / REJECTED. A rejection tells you why so you can fix and resubmit.
  2. Generate live keys — API Keys tab, switch the toggle to Production, click Generate key. Download the .env again.
  3. Swap the keys in your production environment and redeploy. Nothing else in your code changes.

Until KYC is approved, live calls are refused with a clear message. This is deliberate — it is not a bug.

Getting paid out

Your money accumulates in your SP-Heavy wallet. To withdraw it, use Settlements → Request settlement (or sp.settlements.create()); an operator confirms the transfer and the balance is debited.

You do not need a bank account. Set your payout destination in the same KYC panel — either a bank account or a mobile-money number. Most sellers use mobile money, and it works exactly the same way.


11. When something goes wrong

Every error comes back in the same shape:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "amount must be an integer in minor units",
    "requestId": "req_7f3a…"
  }
}

Always log `requestId`. It matches the X-Request-Id header and lets support find your exact request in seconds.

What you seeWhat it meansFix
401 Invalid API credentialsKey wrong, or missing a headerSend both X-Public-Key and X-Secret-Key. Check for a trailing space or newline pasted with the key.
401 API key is revokedThe key was revokedGenerate a new one in API Keys
403 Live payments require an approved KYCUsing an sk_live_ key before approvalFinish KYC — verify as an individual if you have no registered company — or use your sandbox key
422 registrationNumber is requiredSubmitted the business tier without a registration numberUse the individual tier instead: "tier": "INDIVIDUAL"
"Add a payout destination" on settlementNo bank or mobile-money account on fileAdd either in Account → Compliance (KYC)
403 Live payments are not enabledThe platform has not opened live rails yetContact support; use sandbox meanwhile
422 amount must be an integerYou sent 15.50 or "1500"Send whole minor units: 1550
429 Too many requestsRate limitedBack off and retry; add a small delay between calls
Webhook signature never matchesThe body was re-serialisedUse the raw body — see §7
Webhook rejected as "outside tolerance"Your server clock has driftedEnable NTP on the server
Nothing happens after collect()You are waiting for the wrong thingcollect() returns PENDING; the result arrives by webhook
Payment stuck PENDINGCustomer never approved, or the operator went quietPoll status(ref, true); in sandbox, number …0009 does this on purpose

Still stuck? shedpayheavy@gmail.com — include the requestId. Never include your secret key.


12. Launch checklist

Before you take real money:

  • [ ] Secret key lives only on the server, in an environment variable
  • [ ] .env is in .gitignore, and no key was ever committed
  • [ ] Every SP-Heavy call is made from your backend, never the browser
  • [ ] Webhook handler verifies the signature on every request
  • [ ] Webhook handler uses the raw body
  • [ ] Webhook handler is idempotent, deduplicating on X-SPHeavy-Id
  • [ ] You fulfil orders on transaction.successful, never on the collect() reply
  • [ ] You reconcile on `net`, not amount
  • [ ] Idempotency-Key is sent on every collect/disburse (automatic with the SDKs)
  • [ ] All six sandbox outcomes tested, including …0009
  • [ ] requestId is logged on every error
  • [ ] KYC approved and live keys generated
  • [ ] Sandbox keys revoked or removed from production config

Where to go next

Live playground + API referencespheavy.com/docs
Interactive API docs (Swagger)https://api.spheavy.com/docs
OpenAPI spechttps://api.spheavy.com/openapi.json
TypeScript SDKnpmjs.com/package/@yukwaindustries/sph-sdk
Legal termsspheavy.com/legal
Supportshedpayheavy@gmail.com

SP-Heavy is a product of Yukwa Industries.