# SP-Heavy — complete agent reference > Mobile-money payments for Cameroon and the CEMAC zone (MTN MoMo, Orange > Money). Everything below is runnable as written against the sandbox. Code > blocks are executed verbatim by the test suite, so they cannot silently rot. Base URL: https://api.spheavy.com Paths already include /v1. Pasting a base URL that ends in /v1 produces /v1/v1/... and a 404 that looks like a missing endpoint. ## 0. If the project is a new Next.js app npx create-spheavy-app my-shop Scaffolds a working checkout, a signature-verified webhook, and `npm run verify` — an audit that posts forged, stale and tampered deliveries at the endpoint and fails if any is accepted. Prefer this over writing the integration from scratch: everything in it is already correct on the eight rules below. For an existing codebase, or any other stack, follow the rest of this document. ## 1. Rules you must not violate 1. **The secret key is server-side only. Never put `sk_live_`/`sk_test_` in a browser bundle, a mobile app, a React client component, or any file shipped to a user.** Why: Anyone holding it can move money on the account. A key in client code is readable by every visitor. 2. **Verify the signature on EVERY webhook before acting on it.** Why: The endpoint is public. Without verification anyone can POST a fake "payment successful" and get goods for free. 3. **Amounts are integers in MINOR units. XAF and XOF have scale 0, so 1500 means 1500 FCFA. USD/EUR have scale 2, so 1500 means 15.00.** Why: Getting the scale wrong is a payment out by 100x. 4. **Always pass an `Idempotency-Key` derived from your own order id, not a random value.** Why: A random key protects only a retry inside one process. A job runner or a restarted worker sends a new key and charges the payer twice. 5. **A timeout, a network failure or a 5xx is an UNKNOWN outcome, not a failure. Never auto-refund or re-send on one. Reconcile by replaying with the same Idempotency-Key, or by polling the transaction.** Why: The payout may already be on its way. Refunding pays the recipient and the user. 6. **Collections are asynchronous. A 202 from collect means PENDING, not paid. Never fulfil an order on the collect response.** Why: The payer still has to approve on their handset. Fulfil on the `transaction.successful` webhook. 7. **Reconcile on `net`, not `amount`. The platform fee comes out of what you receive.** Why: Reconciling on `amount` is wrong by the fee on every single collection. 8. **Deduplicate webhooks on the `X-SPHeavy-Id` header.** Why: Delivery is at-least-once. The same event can arrive twice and credit a wallet twice. ## 2. Authentication X-Public-Key: pk_test_… X-Secret-Key: sk_test_… Both on every merchant request. Server-side only. Get keys at https://spheavy.com → API Keys → Generate key (leave the toggle on Sandbox). Environment variables used throughout this document: SPHEAVY_BASE_URL=https://api.spheavy.com SPHEAVY_PUBLIC_KEY=pk_test_… SPHEAVY_SECRET_KEY=sk_test_… SPHEAVY_WEBHOOK_SECRET=whsec_… ## 3. Money Requests take `amount` as an INTEGER in minor units. Responses return money as STRINGS in minor units. XAF, XOF scale 0 1500 = 1500 FCFA USD, EUR scale 2 1500 = 15.00 On a Transaction, these are STRINGS in minor units: amount, fee, net. These are NUMBERS in major units, for display only: amountDisplay, feeDisplay, netDisplay. Use BigInt for arithmetic on the strings: `+` on them concatenates, and past 2^53 a JavaScript number cannot hold a zero-scale balance exactly. const net = BigInt(tx.amount) - BigInt(tx.fee); `net` = amount − fee. The platform fee is deducted from what you receive, so reconcile on `net`. ## 4. Take a payment ```js // collect.mjs — Node 18+, no dependencies. const BASE = process.env.SPHEAVY_BASE_URL || 'https://api.spheavy.com'; async function sph(path, { method = 'GET', body, idempotencyKey } = {}) { const res = await fetch(BASE + path, { method, headers: { 'X-Public-Key': process.env.SPHEAVY_PUBLIC_KEY, 'X-Secret-Key': process.env.SPHEAVY_SECRET_KEY, 'Content-Type': 'application/json', ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}), }, body: body ? JSON.stringify(body) : undefined, }); const json = await res.json(); if (!res.ok) { const e = json.error || {}; // outcome is "failed" or "unknown". Never refund on "unknown". const err = new Error(e.message || 'request failed'); err.code = e.code; err.outcome = e.outcome; err.retryable = e.retryable; throw err; } return json.data; } // amount is an INTEGER in minor units. XAF has scale 0, so 1500 = 1500 FCFA. const orderId = 'order-' + Date.now(); const tx = await sph('/v1/payments/collect', { method: 'POST', idempotencyKey: orderId, // stable, from YOUR system body: { amount: 1500, currency: 'XAF', provider: 'MTN', // or 'ORANGE' phoneNumber: '237670000000', // sandbox: always succeeds }, }); console.log(tx.reference, tx.status); // COL_… PENDING <- NOT paid yet ``` The response is HTTP 202 with `status: "PENDING"`. **The customer has not paid yet.** They approve on their handset. Do not fulfil the order here. ## 5. Learn the outcome The correct mechanism is the webhook (section 6). Polling is for scripts and tests: ```js // poll.mjs — only for scripts/tests. In a real app, use the webhook. const BASE = process.env.SPHEAVY_BASE_URL || 'https://api.spheavy.com'; const reference = process.argv[2]; async function status(sync) { const res = await fetch( `${BASE}/v1/payments/status/${reference}?sync=${sync}`, { headers: { 'X-Public-Key': process.env.SPHEAVY_PUBLIC_KEY, 'X-Secret-Key': process.env.SPHEAVY_SECRET_KEY, } }, ); return (await res.json()).data; } const deadline = Date.now() + 120000; let tx = await status(false); while (tx.status === 'PENDING' || tx.status === 'PROCESSING') { if (Date.now() > deadline) throw new Error('still pending — do NOT assume failure'); await new Promise((r) => setTimeout(r, 3000)); tx = await status(Date.now() + 3000 > deadline); } // net = amount - fee. Reconcile on net, never on amount. console.log(tx.status, 'amount=' + tx.amount, 'fee=' + tx.fee, 'net=' + tx.net); ``` Transaction statuses: PENDING → PROCESSING → SUCCESSFUL | FAILED, and SUCCESSFUL → REVERSED after a refund. ## 6. Receive the result (webhook) Set your callback URL at https://spheavy.com → Account → Webhook callback URL, and copy the webhook signing secret from the same screen. Every delivery carries: X-SPHeavy-Signature HMAC-SHA256 of ".", 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 | .failed | .reversed Delivery is at-least-once, retried 6 times with exponential backoff from 30s. Any 2xx stops the retries; anything else is retried. ### Express ```js // webhook.mjs — Express. The signature covers the RAW bytes. import express from 'express'; import crypto from 'node:crypto'; const app = express(); const SECRET = process.env.SPHEAVY_WEBHOOK_SECRET; const seen = new Set(); // in production: Redis, or a unique column function verify(rawBody, signature, timestamp) { if (!signature || !timestamp) return false; // Reject stale deliveries so a captured request cannot be replayed. if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000) return false; const expected = crypto .createHmac('sha256', SECRET) .update(`${timestamp}.${rawBody}`, 'utf8') .digest('hex'); const a = Buffer.from(expected, 'utf8'); const b = Buffer.from(signature, 'utf8'); // Lengths must match before timingSafeEqual, which throws otherwise. return a.length === b.length && crypto.timingSafeEqual(a, b); } app.post( '/webhooks/spheavy', // express.raw, NOT express.json — a JSON parser destroys the exact bytes. express.raw({ type: 'application/json' }), (req, res) => { const raw = req.body.toString('utf8'); if (!verify(raw, req.get('X-SPHeavy-Signature'), req.get('X-SPHeavy-Timestamp'))) { return res.sendStatus(400); } // Delivery is at-least-once. Deduplicate on the event id. const eventId = req.get('X-SPHeavy-Id'); if (seen.has(eventId)) return res.sendStatus(200); seen.add(eventId); const event = JSON.parse(raw); if (event.event === 'transaction.successful') { // Credit the order with net, not amount. console.log('PAID', event.data.reference, 'net=' + event.data.net); } res.sendStatus(200); // any 2xx stops the retries }, ); app.listen(3000); ``` ### Next.js App Router ```ts // app/api/webhooks/spheavy/route.ts — Next.js App Router import crypto from 'node:crypto'; // Node runtime: the Edge runtime has no node:crypto timingSafeEqual. export const runtime = 'nodejs'; export async function POST(request: Request) { const raw = await request.text(); // raw bytes, not request.json() const signature = request.headers.get('x-spheavy-signature'); const timestamp = request.headers.get('x-spheavy-timestamp'); const eventId = request.headers.get('x-spheavy-id'); if (!signature || !timestamp) return new Response(null, { status: 400 }); if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000) { return new Response(null, { status: 400 }); } const expected = crypto .createHmac('sha256', process.env.SPHEAVY_WEBHOOK_SECRET!) .update(`${timestamp}.${raw}`, 'utf8') .digest('hex'); const a = Buffer.from(expected, 'utf8'); const b = Buffer.from(signature, 'utf8'); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { return new Response(null, { status: 400 }); } if (await alreadyHandled(eventId!)) return new Response(null, { status: 200 }); const event = JSON.parse(raw); if (event.event === 'transaction.successful') { await fulfilOrder(event.data.reference, event.data.net); // net, not amount } await remember(eventId!); return new Response(null, { status: 200 }); } ``` ## 7. Settle to your bank or mobile money Collected money sits in your SP-Heavy wallet. GET /v1/balance -> [{ currency, environment, balance, balanceDisplay }] POST /v1/settlements -> { currency } pays the full balance out The destination (bank account or mobile-money number) is set during KYC. A merchant with neither can collect but cannot withdraw. ## 8. Sandbox test numbers The LAST FOUR DIGITS decide the outcome. Any prefix works, MTN or Orange. A sandbox collection is accepted as PENDING and resolves about 6 seconds later; pass `?sync=true` on the status call for an immediate answer. | Phone number | Result | Provider message | | --- | --- | --- | | 237670000000 (or any other) | SUCCESSFUL | SUCCESSFUL | | 237670000001 | FAILED | NOT_ENOUGH_FUNDS | | 237670000002 | FAILED | APPROVAL_REJECTED | | 237670000003 | FAILED | EXPIRED | | 237670000004 | FAILED | PAYER_NOT_FOUND | | 237670000009 | PENDING forever | never resolves | Test all six. …0009 is the one that reveals whether your code copes with a payment that never resolves. ## 9. Errors Every error response has this shape: { "error": { "code": "PROVIDER_ERROR", "message": "…", "retryable": true, "outcome": "unknown", "requestId": "req_…" } } `outcome` is the field that matters: - `"failed"` — the request definitely did not move money. Fail the intent. - `"unknown"` — it may have. Do NOT refund and do NOT re-send blind. Replay with the same Idempotency-Key, or poll `/v1/payments/status/{reference}?sync=true`. | HTTP | Code | Outcome | Retry | What to do | | --- | --- | --- | --- | --- | | 401 | `AUTHENTICATION_ERROR` | `failed` | no | Send both X-Public-Key and X-Secret-Key. Check for whitespace pasted with the key, and that it is not revoked. | | 402 | `INSUFFICIENT_FUNDS` | `failed` | no | Top up or settle less. Replaying immediately fails identically. | | 403 | `AUTHORIZATION_ERROR` | `failed` | no | Resolve the account condition in the message. Retrying changes nothing. | | 404 | `NOT_FOUND` | `failed` | no | Check the path and the reference. A transaction is only visible to the merchant that created it. | | 409 | `CONFLICT` | `failed` | no | Use a fresh key for a genuinely different request, or replay the original body to get the original result. | | 422 | `VALIDATION_ERROR` | `failed` | no | Fix the request from `details`. Replaying it unchanged fails identically. | | 429 | `RATE_LIMITED` | `failed` | yes | Back off for `Retry-After` seconds, then replay with the same Idempotency-Key. | | 500 | `INTERNAL_ERROR` | `unknown` | yes | Treat as indeterminate: replay with the same Idempotency-Key, or poll the transaction status. Quote `requestId` to support. | | 502 | `PROVIDER_ERROR` | `unknown` | yes | Do NOT refund or re-send blind. Replay with the same Idempotency-Key, or poll /v1/payments/status/{reference}?sync=true to establish what happened. | This table is also available as JSON at https://api.spheavy.com/v1/errors. ## 10. Endpoints | Endpoint | Purpose | Auth | | --- | --- | --- | | POST /v1/payments/collect | Initiate a collection (request to pay) | API key | | POST /v1/payments/disburse | Initiate a disbursal (payout) | API key | | GET /v1/payments/status/{reference} | Get transaction status | API key | | GET /v1/transactions | List transactions | API key | | GET /v1/transactions/{reference} | Get a transaction | API key | | POST /v1/transactions/{reference}/refund | Refund a successful collection | API key | | GET /v1/balance | Wallet balances (for the key environment) | API key | | POST /v1/payment-links | Create a hosted-checkout link | API key | | GET /v1/payment-links | List payment links | API key | | GET /v1/payment-links/{id} | Get a payment link | API key | | POST /v1/payment-links/{id}/deactivate | Deactivate a payment link | API key | | GET /v1/checkout/{token} | Get hosted-checkout display data | none | | POST /v1/checkout/{token}/pay | Pay a hosted-checkout link (amount is fixed server-side) | none | | GET /v1/checkout/{token}/status/{reference} | Poll a checkout payment status | none | | POST /v1/settlements | Request a settlement of the wallet balance | API key | | GET /v1/settlements | List settlements | API key | Full machine-readable description: https://api.spheavy.com/openapi.json ## 11. Going live Sandbox needs nothing. Live payments require an approved KYC, in one of two tiers — an individual verified from a government ID, or a registered business verified from its registration number. Neither a company nor a bank account is required: an individual can settle to a mobile-money number. Submit at https://spheavy.com → Account → KYC, then generate live keys and swap the two environment variables. Nothing else in the code changes.