Node.js SDK
@soamtar/sdk is the official Node.js SDK: collect payments and send payouts with Orange Money Sierra Leone, in leones (SLE).
Server side only
The SDK authenticates with your secret key (sk_test_… / sk_live_…) and refuses any other key. Never ship that key to a browser or a mobile app: there, use the browser SDK with your public key.
Installation
npm install @soamtar/sdkNode.js 18 or later, no runtime dependencies. Current version: 1.1.0 (see the SDK's package.json and CHANGELOG). Until the package is published on npm, install it from the SDK repository.
Configuration
const soamtar = require('@soamtar/sdk')(process.env.SOAMTAR_SECRET_KEY);
// or, with ES modules / TypeScript:
// import soamtarFactory from '@soamtar/sdk'
// const soamtar = soamtarFactory(process.env.SOAMTAR_SECRET_KEY)| Environment variable | Role |
|---|---|
SOAMTAR_SECRET_KEY | Secret key, read when none is passed to the factory. |
SOAMTAR_API_URL | Overrides the base URL (default https://app.so-amtar.com/v1). SOAMTAR_BASE_URL is still read as a fallback (deprecated name). |
Options (second argument): baseUrl, timeout (ms, default 15000), signRequests (see Request signing).
There is no separate sandbox host: an sk_test_ key goes through simulation on the same URL. soamtar.isTestMode tells you which key is loaded.
Create a payment
const { data: payment } = await soamtar.payments.create({
merchant_ref: 'ORDER-1042',
amount: '1250.00', // SLE, 2 decimals
phone: '+23276123456', // +232 followed by 8 digits
description: 'Order #1042',
success_url: 'https://shop.example.sl/thanks',
failed_url: 'https://shop.example.sl/payment-failed',
callback_url: 'https://shop.example.sl/webhooks/soamtar', // optional
})
// Send the customer to the hosted checkout, then wait for the webhook.
console.log(payment.id, payment.status, payment.payment_url)currencydefaults toSLE(USDandEURare also accepted) andprovidertoorange_money_sl, the only provider.- Amounts are sent as strings with two decimals:
toAmount(1250)→"1250.00". - The customer approves the payment on their phone with their Orange Money PIN.
- Notifications are sent to the webhook endpoints you register (dashboard or
webhooks.create). The optionalcallback_url(HTTPS) also receives every event of this payment, with the same payload and the sameX-Soamtar-Signature, so verify it exactly like your endpoint (you need at least one registered endpoint: its secret signs the callback). Same forcallback_urlon payouts.
API
Every method returns the parsed JSON response: single objects as { data }, lists as { data: [], meta } (refunds.list() returns { data: [], pagination }).
| Method | Endpoint |
|---|---|
payments.create(params, { idempotencyKey? }) | POST /v1/payments |
payments.retrieve(id) | GET /v1/payments/{id} |
payments.list({ per_page, page }) | GET /v1/payments (per_page 1–100, default 25) |
payments.verify(id) | POST /v1/payments/{id}/verify (re-reads the status from Orange Money) |
payments.status(idOrReference) | GET /v1/status/{idOrReference} |
payouts.create(params, { idempotencyKey? }) | POST /v1/payouts |
payouts.retrieve(id) | GET /v1/payouts/{id} |
payouts.list(query) | GET /v1/payouts |
refunds.create({ transaction_id, amount?, reason?, description? }, { idempotencyKey? }) | POST /v1/refunds |
refunds.retrieve(idOrReference) | GET /v1/refunds/{id} (id or REF-… reference) |
refunds.list({ status?, transaction_id?, per_page?, page? }) | GET /v1/refunds |
balance.retrieve() | GET /v1/balance |
webhooks.list() | GET /v1/webhooks |
webhooks.create({ url, subscribed_events?, description?, api_version? }) | POST /v1/webhooks (idempotent by URL; subscribed_events defaults to payment.success, payment.failed, payout.success, payout.failed) |
webhooks.secret(endpointId) | GET /v1/webhooks/{endpoint_id}/secret |
webhooks.lookup(url) | GET /v1/webhooks/lookup?url=… |
webhooks.sendTest() | POST /v1/webhooks/test (sends a webhook.test event; webhooks.test() is a deprecated alias) |
request(method, path, body?, { query?, idempotencyKey? }) | any other endpoint |
retrieve() and status() take the SOAMTAR id or reference (TXN-…), not your merchant_ref.
Idempotency
Every POST carries an Idempotency-Key header. The SDK generates a UUID v4 per call; pass your own key so that a retry never charges twice:
await soamtar.payments.create(params, { idempotencyKey: `order-${order.id}` })Refunds
Refund a successful payment, fully or partially: SOAMTAR credits the amount back to the payer's Orange Money number and debits your balance. The key needs the can_refund scope.
const { data: refund } = await soamtar.refunds.create({
transaction_id: payment.id, // SOAMTAR id (or reference) of the payment
amount: '400.00', // omit for a full refund of what remains refundable
reason: 'customer_request',
description: 'Partial refund for order #1042',
}, { idempotencyKey: `refund-${order.id}-1` })
console.log(refund.reference, refund.status) // "REF-…", "processing"
const { data: again } = await soamtar.refunds.retrieve(refund.reference)
const { data: refunds, pagination } = await soamtar.refunds.list({ transaction_id: payment.id })The refund is submitted in the same request: with a test key it comes back success; with Orange Money it is processing until Orange confirms; failed (with failure_reason) if the credit is refused. The final outcome arrives by webhook: refund.success or refund.failed, plus payment.refunded / payment.partially_refunded on success. See Refunds for the object and error codes.
Errors
Failed calls reject with a SoamtarError:
const { SoamtarError } = require('@soamtar/sdk')
try {
await soamtar.payments.create({ merchant_ref: 'ORDER-1', amount: '0' })
} catch (e) {
if (e instanceof SoamtarError) {
console.error(e.status, e.code, e.message, e.errors, e.requestId)
// 422 validation_error "…" { amount: ['…'] } "req_…"
}
}code is the API's error.code (validation_error, …), or timeout, connection_error, invalid_response for transport failures. Quote requestId (the X-Request-Id header) when you contact support. Retry 5xx and network errors with the same idempotencyKey; never retry a 4xx as is.
Webhooks
SOAMTAR signs each delivery with X-Soamtar-Signature: t=<unix>,v1=<hex>, an HMAC-SHA256 of "<t>.<raw body>" with the endpoint secret. Webhook.constructEvent() checks it against the raw body and returns the parsed event:
const express = require('express')
const { Webhook } = require('@soamtar/sdk')
const app = express()
app.post('/webhooks/soamtar', express.raw({ type: 'application/json' }), (req, res) => {
let event
try {
event = Webhook.constructEvent(
req.body,
req.get('X-Soamtar-Signature'),
process.env.SOAMTAR_WEBHOOK_SECRET,
)
} catch (e) {
return res.status(400).send(e.message)
}
if (event.event === 'payment.success') {
// event.data.id, event.data.reference, event.data.merchant_reference,
// event.data.amount, event.data.currency …
}
res.sendStatus(200)
})- Deliveries older than 300 seconds are rejected (
{ tolerance }as fourth argument to change it). - During a secret rotation the header also carries
v2=…. Pass both secrets ([secret, secret_next], as returned bywebhooks.secret()) and either signature is accepted. - Endpoints created with
api_version: '2026-06-01'receive{ id, object: 'event', type, created, data: { object } }instead of{ event, created, data }. - Events you can subscribe to:
payment.success,payment.failed,payment.cancelled,payment.expired,payment.refunded,payment.partially_refunded,payout.success,payout.failed,payout.cancelled,refund.success,refund.failed,payout_request.submitted,payout_request.approved,payout_request.rejected,payout_request.completed,payout_request.failed. See Webhooks. webhook.testis sent bywebhooks.sendTest()and by the dashboard's test button, whatever your subscriptions: answer it with a 2xx.
Raw body
Most Node frameworks parse JSON automatically. Expose the raw body (string or Buffer) on the webhook route, otherwise the signature will never match. With NestJS, create the app with rawBody: true and pass req.rawBody; with Fastify, register a raw-body parser for that route.
Request signing
require('@soamtar/sdk')(key, { signRequests: true }) adds Soamtar-Timestamp and Soamtar-Signature: v1=hex(HMAC_SHA256(secretKey, "<timestamp>.<METHOD>.<path>")) to every POST (<path> without the leading slash, e.g. v1/payments; timestamp within ±300 s). It is optional hardening: the Bearer key over HTTPS already authenticates you. See Authentication.
Helpers
const { toAmount, formatLe } = require('@soamtar/sdk')
toAmount(1250) // "1250.00"
formatLe('1250') // "Le 1,250.00"TypeScript
The package ships its own types (index.d.ts): SoamtarClient, CreatePaymentParams, CreatePayoutParams, CreateRefundParams, Refund, WebhookEventType, SoamtarError, Webhook.
import soamtarFactory, { SoamtarError, type CreatePaymentParams } from '@soamtar/sdk'
const soamtar = soamtarFactory(process.env.SOAMTAR_SECRET_KEY)
const params: CreatePaymentParams = { merchant_ref: 'ORDER-1043', amount: '500.00', phone: '+23277123456' }
const { data } = await soamtar.payments.create(params)Testing your webhook handler
import { createHmac } from 'node:crypto'
import { test } from 'node:test'
import assert from 'node:assert'
import { Webhook } from '@soamtar/sdk'
test('accepts a fresh valid signature', () => {
const secret = 'whsec_test'
const t = Math.floor(Date.now() / 1000)
const body = JSON.stringify({ event: 'payment.success', created: new Date().toISOString(), data: {} })
const sig = `t=${t},v1=${createHmac('sha256', secret).update(`${t}.${body}`).digest('hex')}`
assert.strictEqual(Webhook.constructEvent(body, sig, secret).event, 'payment.success')
})