📖 [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.
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.
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 does | When you use it | |
|---|---|---|
| Collect | Pulls money from a customer to your wallet | Checkout, invoices, subscriptions |
| Disburse | Sends money from your wallet to someone | Payouts, 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.
You get two strings:
| Key | Looks like | What it is |
|---|---|---|
| Public key | pk_test_a1b2c3… | Identifies your business. Not secret. |
| Secret key | sk_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.
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
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.
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.
237670000000 is a magic test number that always succeeds in the sandbox.
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… PENDINGfrom 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
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… PENDINGcurl -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.)
Idempotency-Key mattersIf 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}`);The response is 202 Accepted with status `PENDING`. That is correct and expected: the customer has not touched their phone yet.
| Status | Meaning | What you should do |
|---|---|---|
PENDING | Waiting for the customer's PIN | Show "waiting for approval" |
PROCESSING | Operator is moving the money | Keep waiting |
SUCCESSFUL | Money received | Fulfil the order |
FAILED | Declined / no funds / expired | Show the reason, offer a retry |
REVERSED | Refunded afterwards | Undo the fulfilment |
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.
net, not amountEvery successful collection is charged a platform fee. The transaction carries three numbers:
| Field | Meaning |
|---|---|
amount | What the customer paid — 1500 |
fee | What SP-Heavy charged — 22 at the default 1.5% |
net | What 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.
A webhook is SP-Heavy calling your server when something happens. This is how you learn a payment succeeded.
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_…
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 "", 200PHP
<?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.
| Header | Meaning |
|---|---|
X-SPHeavy-Signature | HMAC-SHA256 of <timestamp>.<raw body>, hex |
X-SPHeavy-Timestamp | Epoch milliseconds, part of the signed string |
X-SPHeavy-Id | Stable event id — deduplicate on this |
X-SPHeavy-Event | transaction.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.
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.
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 nowYou 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).
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 number | Result | Message you get back |
|---|---|---|
237670000000 (or any other) | ✅ SUCCESSFUL | SUCCESSFUL |
237670000001 | ❌ FAILED | NOT_ENOUGH_FUNDS |
237670000002 | ❌ FAILED | APPROVAL_REJECTED — customer declined |
237670000003 | ❌ FAILED | EXPIRED — customer ignored the prompt |
237670000004 | ❌ Rejected instantly | PAYER_NOT_FOUND — wrong number |
237670000009 | ⏳ PENDING forever | simulates 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.
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:
Choose "I'm not registered yet" in Account → Compliance (KYC). You verify yourself, not a company:
| What you provide | Notes |
|---|---|
| Full name as printed on your ID | Must match the document exactly |
| Date of birth | You must be 18 or over |
| Document type | National ID card or passport |
| ID / passport number | |
| Phone number | |
| Photo of the front of the ID | All four corners visible, text readable |
| Photo of the back of the ID | |
| Selfie holding the front of your ID | Your 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.
Choose "Registered business" and provide the registered legal name, the owner or director's name, and your business registration number.
NOT_SUBMITTED → PENDING → APPROVED / REJECTED. A rejection tells you why so you can fix and resubmit..env again.Until KYC is approved, live calls are refused with a clear message. This is deliberate — it is not a bug.
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.
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 see | What it means | Fix |
|---|---|---|
401 Invalid API credentials | Key wrong, or missing a header | Send both X-Public-Key and X-Secret-Key. Check for a trailing space or newline pasted with the key. |
401 API key is revoked | The key was revoked | Generate a new one in API Keys |
403 Live payments require an approved KYC | Using an sk_live_ key before approval | Finish KYC — verify as an individual if you have no registered company — or use your sandbox key |
422 registrationNumber is required | Submitted the business tier without a registration number | Use the individual tier instead: "tier": "INDIVIDUAL" |
| "Add a payout destination" on settlement | No bank or mobile-money account on file | Add either in Account → Compliance (KYC) |
403 Live payments are not enabled | The platform has not opened live rails yet | Contact support; use sandbox meanwhile |
422 amount must be an integer | You sent 15.50 or "1500" | Send whole minor units: 1550 |
429 Too many requests | Rate limited | Back off and retry; add a small delay between calls |
| Webhook signature never matches | The body was re-serialised | Use the raw body — see §7 |
| Webhook rejected as "outside tolerance" | Your server clock has drifted | Enable NTP on the server |
Nothing happens after collect() | You are waiting for the wrong thing | collect() returns PENDING; the result arrives by webhook |
Payment stuck PENDING | Customer never approved, or the operator went quiet | Poll status(ref, true); in sandbox, number …0009 does this on purpose |
Still stuck? shedpayheavy@gmail.com — include the requestId. Never include your secret key.
Before you take real money:
.env is in .gitignore, and no key was ever committedX-SPHeavy-Idtransaction.successful, never on the collect() replyamountIdempotency-Key is sent on every collect/disburse (automatic with the SDKs)…0009requestId is logged on every error| Live playground + API reference | spheavy.com/docs |
| Interactive API docs (Swagger) | https://api.spheavy.com/docs |
| OpenAPI spec | https://api.spheavy.com/openapi.json |
| TypeScript SDK | npmjs.com/package/@yukwaindustries/sph-sdk |
| Legal terms | spheavy.com/legal |
| Support | shedpayheavy@gmail.com |
SP-Heavy is a product of Yukwa Industries.