Integrate SOAMTAR on your website
This guide shows how to accept Orange Money Sierra Leone payments on your site in three steps:
- Server side: create a transaction (a request authenticated with your secret key).
- Browser side: open the SOAMTAR payment window with the JS SDK.
- Server side: receive and verify the final webhook.
Recommended architecture: the JS popup never talks to SOAMTAR with your secret key. It calls a route on your backend, which calls SOAMTAR. That is the only way to keep
sk_live_…out of the browser.
Flow overview
Browser Your backend SOAMTAR Customer's phone
| click "Pay" | | |
|------------------------>| POST /v1/payments | |
| |------------------------->| |
| |<-------------------------| payment_url, id |
|<------------------------| { paymentUrl } | |
| SOAMTAR window opens | | Orange Money request |
|-------------------------------------------------> |------------------------->|
| | |<-------------------------|
| | | customer enters PIN |
| |<-------------------------| |
| | signed webhook | |
| | payment.success | |
|<------------------------| return_url / JS callback| |1. Prerequisites
- A merchant account on https://app.so-amtar.com (KYC approved for live mode).
- Your API keys from Developers → API keys:
sk_test_…for the sandboxsk_live_…for production
- A public HTTPS endpoint for webhooks (test mode accepts ngrok / cloudflared tunnels).
Never expose sk_live_ in the browser
Every SOAMTAR API key is secret. The JS window never needs it: it calls your backend, which makes the server-to-server request.
2. Backend: create a transaction
Your backend exposes one internal route (for example POST /checkout/soamtar) that the browser calls. This route:
- Checks the user and the cart.
- Calls
POST https://app.so-amtar.com/v1/payments. - Returns
payment_urlandidto the browser.
SOAMTAR endpoint
| Method | URL |
|---|---|
POST | https://app.so-amtar.com/v1/payments |
Headers:
| Header | Value |
|---|---|
Authorization | Bearer sk_test_… or sk_live_… |
Content-Type | application/json |
Body:
| Field | Type | Required | Description |
|---|---|---|---|
merchant_ref | string | yes | Your unique reference (order number). |
amount | numeric | yes | Amount in leones, at most two decimals (e.g. 150.00 = Le 150.00). |
currency | string | no | SLE by default. |
provider | string | no | orange_money_sl. Omit it to use the SOAMTAR payment window (Hosted Checkout). |
payer.phone | string | recommended | E.164 format (+232 followed by 8 digits). Pre-fills the payment window. |
payer.name | string | no | Name shown on the receipt. |
return_url | string | recommended | Where the customer lands after a successful payment (alias of success_url). |
cancel_url | string | recommended | Where the customer lands after a failure or cancellation (alias of failed_url). |
callback_url | string | no | HTTPS URL that receives every webhook event of this payment, signed like your endpoints (requires at least one registered webhook endpoint). See Webhooks. |
metadata | object | no | Up to 50 free keys, sent back in the webhook. |
Examples
// routes/web.php or routes/api.php
Route::post('/checkout/soamtar', function (Request $request) {
$order = Order::findOrFail($request->integer('order_id'));
$response = Http::withToken(config('services.soamtar.key'))
->acceptJson()
->post('https://app.so-amtar.com/v1/payments', [
'merchant_ref' => $order->reference,
'amount' => $order->total_sle,
'currency' => 'SLE',
'payer' => [
'phone' => $order->customer_phone,
'name' => $order->customer_name,
],
'return_url' => route('checkout.success', $order),
'cancel_url' => route('checkout.cancel', $order),
'metadata' => [
'order_id' => $order->id,
'user_id' => $request->user()->id,
],
])
->throw()
->json('data');
return response()->json([
'id' => $response['id'],
'paymentUrl' => $response['payment_url'],
]);
})->middleware('auth');import express from 'express'
const app = express()
app.use(express.json())
app.post('/checkout/soamtar', async (req, res) => {
const order = await db.orders.findById(req.body.order_id)
const r = await fetch('https://app.so-amtar.com/v1/payments', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SOAMTAR_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
merchant_ref: order.reference,
amount: order.total_sle,
currency: 'SLE',
payer: { phone: order.customerPhone, name: order.customerName },
return_url: `${process.env.APP_URL}/checkout/success?id=${order.id}`,
cancel_url: `${process.env.APP_URL}/checkout/cancel?id=${order.id}`,
metadata: { order_id: order.id, user_id: req.user.id },
}),
})
if (!r.ok) return res.status(502).json({ error: await r.text() })
const { data } = await r.json()
res.json({ id: data.id, paymentUrl: data.payment_url })
})import os, httpx
from fastapi import APIRouter, Depends, HTTPException
router = APIRouter()
@router.post("/checkout/soamtar")
async def checkout(order_id: int, user = Depends(auth)):
order = await db.orders.get(order_id)
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
"https://app.so-amtar.com/v1/payments",
headers={
"Authorization": f"Bearer {os.environ['SOAMTAR_SECRET_KEY']}",
},
json={
"merchant_ref": order.reference,
"amount": order.total_sle,
"currency": "SLE",
"payer": {"phone": order.customer_phone, "name": order.customer_name},
"return_url": f"{APP_URL}/checkout/success?id={order.id}",
"cancel_url": f"{APP_URL}/checkout/cancel?id={order.id}",
"metadata": {"order_id": order.id, "user_id": user.id},
},
)
if r.status_code >= 400:
raise HTTPException(502, r.text)
data = r.json()["data"]
return {"id": data["id"], "paymentUrl": data["payment_url"]}SOAMTAR response
{
"data": {
"id": "0e85b3c2-4a01-4e8f-b8b8-b71e0e1c8d1a",
"object": "transaction",
"reference": "TXN-A1B2C3D4E5",
"status": "pending",
"amount": "150.00",
"currency": "SLE",
"payment_url": "https://app.so-amtar.com/pay/checkout/0e85b3c2-...",
"expires_at": "2026-04-15T12:30:00+00:00"
},
"livemode": false
}Your backend must store data.id and data.reference and link them to your order_id before replying to the browser. That table is what you use to match the webhook later.
3. Frontend: the SOAMTAR payment window
The JS SDK is a dependency-free script. It opens payment_url in a modal window, listens to payment events and calls you back when the payment is finished.
Include the script
<script src="https://app.so-amtar.com/sdk/soamtar.js" defer></script>Served by SOAMTAR with a one-day cache.
Simple integration: popup
<button id="pay-btn" data-order-id="42">
Pay Le 150.00
</button>
<script>
document.getElementById('pay-btn').addEventListener('click', async (e) => {
const orderId = e.currentTarget.dataset.orderId;
// 1) Ask YOUR backend to create the SOAMTAR transaction.
const res = await fetch('/checkout/soamtar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_id: orderId }),
});
const { id, paymentUrl } = await res.json();
// 2) Open the window on the URL returned by SOAMTAR.
new SOAMTAR({ transactionId: id, paymentUrl })
.withOption({
presentationMode: SOAMTAR.OPEN_IN_POPUP,
locale: 'en',
didReceivePayment: (event) => {
// event.status === 'success' | 'failed' | 'cancelled'
if (event.status === 'success') {
window.location.href = '/checkout/success?id=' + orderId;
}
},
didCancel: () => console.info('Payment cancelled by the customer'),
didReceiveError: (err) => console.error('[SOAMTAR]', err),
})
.send();
});
</script>Declarative integration: data-* attributes
For a static site or a CMS, no custom JavaScript is needed:
<button
data-soamtar
data-soamtar-request-url="/checkout/soamtar"
data-soamtar-body='{"order_id": 42}'
data-soamtar-success-url="/checkout/success"
data-soamtar-mode="popup">
Pay
</button>The SDK scans [data-soamtar] on load, calls the given URL with POST, reads paymentUrl from the JSON response and opens the window.
Presentation modes
| Constant | Behaviour |
|---|---|
SOAMTAR.OPEN_IN_POPUP | Modal centred above the page (default). |
SOAMTAR.OPEN_IN_NEW_TAB | Opens a new tab (fallback when popups are blocked). |
SOAMTAR.OPEN_IN_SAME_TAB | Full-page redirect to payment_url. |
SOAMTAR.EMBED | Inline in a <div id="soamtar-frame"></div> you provide. |
SOAMTAR.HEADLESS | Opens nothing and returns paymentUrl: you display it yourself. |
Callbacks
| Option | Signature | When |
|---|---|---|
didReceivePayment | ({ status, transactionId, reference }) | The window received a final event. |
didCancel | () | The customer closed the window or clicked "cancel". |
didReceiveError | (error) | Network error, expired token, CSP… |
didClose | () | Always called when the window closes (after the others). |
Event ≠ proof of payment
JS callbacks are informational (the customer can close the tab early, lose network coverage, etc.). Never mark an order as paid from the browser: only the webhook is authoritative.
Vue.js / Inertia
<script setup lang="ts">
import { ref } from 'vue'
const loading = ref(false)
async function pay(orderId: number) {
loading.value = true
const { id, paymentUrl } = await fetch('/checkout/soamtar', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf() },
body: JSON.stringify({ order_id: orderId }),
}).then(r => r.json())
new window.SOAMTAR({ transactionId: id, paymentUrl })
.withOption({
presentationMode: window.SOAMTAR.OPEN_IN_POPUP,
didReceivePayment: () => router.visit(`/orders/${orderId}`),
didClose: () => (loading.value = false),
})
.send()
}
</script>
<template>
<button :disabled="loading" @click="pay(42)">Pay</button>
</template>React
import { useState } from 'react'
declare global { interface Window { SOAMTAR: any } }
export function PayButton({ orderId }: { orderId: number }) {
const [loading, setLoading] = useState(false)
async function pay() {
setLoading(true)
const { id, paymentUrl } = await fetch('/checkout/soamtar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_id: orderId }),
}).then(r => r.json())
new window.SOAMTAR({ transactionId: id, paymentUrl })
.withOption({
presentationMode: window.SOAMTAR.OPEN_IN_POPUP,
didReceivePayment: e => e.status === 'success' && (location.href = '/success'),
didClose: () => setLoading(false),
})
.send()
}
return <button disabled={loading} onClick={pay}>Pay</button>
}4. Webhook: the only source of truth
SOAMTAR sends a POST to your endpoint as soon as the status changes. The request is signed in the X-Soamtar-Signature header (t=<timestamp>,v1=<hmac>).
Route::post('/webhooks/soamtar', function (Request $request) {
$header = $request->header('X-Soamtar-Signature', '');
$body = $request->getContent();
$secret = config('services.soamtar.webhook_secret');
if (! preg_match('/^t=(\d+),v1=([a-f0-9]{64})/', $header, $m)) {
abort(400, 'bad signature');
}
if (abs(time() - (int) $m[1]) > 300) {
abort(401, 'expired');
}
$expected = hash_hmac('sha256', $m[1] . '.' . $body, $secret);
if (! hash_equals($expected, $m[2])) {
abort(401, 'invalid');
}
$event = json_decode($body, true);
match ($event['event']) {
'payment.success' => Order::markPaid($event['data']['merchant_reference']),
'payment.failed' => Order::markFailed($event['data']['merchant_reference']),
'payment.cancelled' => Order::markCancelled($event['data']['merchant_reference']),
default => null,
};
return response()->json(['received' => true]);
});Idempotency
Each event is delivered at least once. Use the pair event + data.id as a unique key in your database: if you have already processed it, reply 200 and do nothing.
Full details, secret rotation and versioned formats: see Webhooks.
5. Sandbox vs production
| Sandbox | Production | |
|---|---|---|
| Key | sk_test_… | sk_live_… |
| Base URL | https://app.so-amtar.com/v1 | same |
| Orange Money | Simulated (Simulate success / failure buttons) | Real, KYC required |
| Money moved | None | Exact amount |
livemode in responses | false | true |
To force a state in the sandbox, open payment_url in the window and click the matching button. The webhook fires exactly as in production.
6. Checklist before going live
- [ ] Your backend stores
transaction.idandmerchant_refbefore replying to the browser. - [ ] The window never decides the order status on its own: the webhook is the source of truth.
- [ ] Your webhook endpoint checks
t,v1and the 5-minute window, and replies2xxin under 5 s. - [ ]
return_urlandcancel_urlshow the real status (from your database, fed by the webhook), not an optimistic confirmation. - [ ] Logs never store the API key in clear text.
- [ ] Your CSP allows
https://app.so-amtar.com(script-src, frame-src, connect-src) if you use the window. - [ ] Full sandbox test: success, failure, cancellation, expiry, duplicate webhook.
See also the detailed integration checklist and Test → Production.
7. Frequently asked questions
Is the popup mandatory? No. You can redirect straight to payment_url (window.location.href = paymentUrl). The popup gives a better experience because the customer stays on your page.
Can I call /v1/payments directly from the browser? No, never. It would expose your API key. The internal /checkout/soamtar route is the only safe way.
How long is a payment_url valid? Until expires_at, returned at creation (2 hours by default on the platform). After that, SOAMTAR sends payment.expired.
Can I have several simultaneous payments for one order? Yes, but avoid it. Use a unique merchant_ref per attempt (e.g. ORDER-42-v2) to keep a 1:1 link for reconciliation.
What does the customer see on their phone? An Orange Money request from your business for the exact amount in leones. They approve it with their Orange Money PIN; nothing else to install.