Intégrer SOAMTAR sur votre site
Ce guide vous montre comment encaisser Orange Money Sierra Leone sur votre site en trois étapes :
- Côté serveur : créer une transaction (une requête signée avec votre clé secrète).
- Côté navigateur : envoyer le payeur vers la page de paiement SOAMTAR avec le SDK JS.
- Côté serveur : recevoir et vérifier le webhook final.
Architecture recommandée : le navigateur ne parle jamais à SOAMTAR avec votre clé secrète. Il appelle une route de votre backend, qui elle appelle SOAMTAR. C'est la seule façon de ne pas exposer
sk_live_dans le navigateur.
Vue d'ensemble du flux
Navigateur Votre backend SOAMTAR Téléphone du client
| clic « Payer » | | |
|------------------------>| POST /v1/payments | |
| |------------------------->| |
| |<-------------------------| payment_url, id |
|<------------------------| { paymentUrl } | |
| fenêtre SOAMTAR | | demande Orange Money |
|-------------------------------------------------> |------------------------->|
| | |<-------------------------|
| | | le client saisit PIN |
| |<-------------------------| |
| | webhook signé | |
| | payment.success | |
|<------------------------| return_url / cancel_url | |1. Pré-requis
- Un compte marchand sur https://app.so-amtar.com (KYC validé pour la prod).
- Vos clés API depuis Développeurs → Clés API :
sk_test_…pour le sandboxsk_live_…pour la production
- Un endpoint HTTPS public pour le webhook (le mode test accepte ngrok / cloudflared).
Ne jamais exposer sk_live_ côté navigateur
Toute clé API SOAMTAR est secrète. Le navigateur n'a jamais besoin de la clé : il appelle votre backend, qui signe la requête serveur-à-serveur.
2. Backend : créer une transaction
Le backend expose une seule route interne (par ex. POST/checkout/soamtar) que le navigateur appelle. Cette route :
- Vérifie l'utilisateur / le panier.
- Appelle
POST https://app.so-amtar.com/v1/payments. - Renvoie au navigateur
payment_urletid.
Endpoint SOAMTAR
| Méthode | URL |
|---|---|
POST | https://app.so-amtar.com/v1/payments |
Headers :
| Header | Valeur |
|---|---|
Authorization | Bearer sk_test_ ou sk_live_ |
Content-Type | application/json |
Body :
| Champ | Type | Obligatoire | Description |
|---|---|---|---|
merchant_ref | string | oui | Référence unique côté marchand (numéro de commande). |
amount | numeric | oui | Montant en leones, deux décimales max (ex. 150.00 = 150 SLE). |
currency | string | non | SLE par défaut. |
provider | string | non | orange_money_sl. Omettre pour passer par la page de paiement hébergée (Hosted Checkout). |
payer.phone | string | recommandé | Format E.164 (+232 + 8 chiffres). Pré-remplit la page de paiement. |
payer.name | string | non | Nom affiché sur le reçu. |
return_url | string | recommandé | URL de retour après succès (alias de success_url). |
cancel_url | string | recommandé | URL de retour en cas d'échec ou d'annulation (alias de failed_url). |
callback_url | string | non | URL HTTPS qui reçoit tous les événements webhook de ce paiement, signés comme pour vos endpoints (au moins un endpoint webhook enregistré requis). Voir Webhooks. |
metadata | object | non | Jusqu'à 50 clés libres, renvoyées dans le webhook. |
Exemples
// routes/web.php ou routes/api.php
Route::post('/checkout/soamtar', function (Request $request) {
$order = Order::findOrFail($request->integer('order_id'));
abort_unless($order->user_id === $request->user()->id, 404); // uniquement la commande de l'acheteur
$response = Http::withToken(config('services.soamtar.key'))
->acceptJson()
->withHeaders(['Idempotency-Key' => "order-{$order->reference}"])
->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)
if (!order || order.userId !== req.user.id) return res.sendStatus(404) // uniquement la commande de l'acheteur
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',
'Idempotency-Key': `order-${order.reference}`,
},
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: 'payment_unavailable' }) // journalisez r.status côté serveur ; ne renvoyez jamais l'erreur de l'API au navigateur
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)
if order is None or order.user_id != user.id:
raise HTTPException(404) # uniquement la commande de l'acheteur
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']}",
"Idempotency-Key": f"order-{order.reference}",
},
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, "payment_unavailable") # journalisez r.text côté serveur uniquement
data = r.json()["data"]
return {"id": data["id"], "paymentUrl": data["payment_url"]}Réponse SOAMTAR
{
"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
}Votre backend doit persister data.id et data.reference et les lier à votre order_id avant de répondre au navigateur. C'est cette table qui sert ensuite à rapprocher le webhook.
3. Frontend : envoyer le payeur vers la page de paiement
Le SDK navigateur est un script sans dépendance qui n'accepte que votre clé publique (pk_test_… / pk_live_…). Il ouvre le payment_url renvoyé par votre backend dans le même onglet ou dans un nouvel onglet. Il n'ouvre ni popup ni iframe et ne remonte pas l'issue du paiement : le payeur revient sur votre return_url / cancel_url, et c'est le webhook qui règle la commande.
Inclure le script
<script src="https://app.so-amtar.com/sdk/soamtar.js"></script>Servi par SOAMTAR avec un cache d'un jour. Sans le SDK,
window.location.href = paymentUrlfait la même chose.
Bouton de paiement
<button id="pay-btn" data-order-id="42">Payer Le 150.00</button>
<script>
document.getElementById('pay-btn').addEventListener('click', async (e) => {
const orderId = e.currentTarget.dataset.orderId;
// 1) Demander à VOTRE backend de créer le paiement : il vérifie la commande et le montant.
const res = await fetch('/checkout/soamtar', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({ order_id: orderId }),
});
if (!res.ok) return alert('Paiement indisponible, réessayez.');
const { paymentUrl } = await res.json();
// 2) Envoyer le payeur vers la page de paiement SOAMTAR (clé publique uniquement).
SoamtarSDK('pk_test_your_public_key')
.setup({ url: paymentUrl })
.withConfig({ onError: (message) => alert(message) })
.start();
});
</script>.start() n'ouvre que des URL HTTPS sur app.so-amtar.com (autres hôtes via allowedHosts) et lève une erreur sur une clé secrète. Le SDK peut aussi appeler lui-même votre backend (requestTokenUrl) : voir JavaScript (navigateur).
Modes de présentation
| Constante | Comportement |
|---|---|
SoamtarSDK.REDIRECT_SELF | Même onglet (défaut). |
SoamtarSDK.REDIRECT_NEW | Nouvel onglet : .withConfig({ presentationMode: SoamtarSDK.REDIRECT_NEW }). |
Le navigateur ne prouve jamais un paiement
onSuccess et onCancel n'existent que par compatibilité et ne sont jamais appelés. Arriver sur votre return_url ne prouve rien non plus : ne marquez la commande payée que sur le webhook (ou GET/v1/payments/{id} depuis votre backend).
React (mêmes étapes en Vue ou dans tout framework)
import { useState } from 'react'
declare global { interface Window { SoamtarSDK: (publicKey: string) => any } }
export function PayButton({ orderId, csrfToken }: { orderId: number; csrfToken: string }) {
const [loading, setLoading] = useState(false)
async function pay() {
setLoading(true)
const res = await fetch('/checkout/soamtar', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: JSON.stringify({ order_id: orderId }),
})
if (!res.ok) return setLoading(false)
const { paymentUrl } = await res.json()
// La page part vers la page de paiement ; l'issue revient sur return_url et par webhook.
window.SoamtarSDK(import.meta.env.VITE_SOAMTAR_PUBLIC_KEY)
.setup({ url: paymentUrl })
.withConfig({ onError: () => setLoading(false) })
.start()
}
return <button disabled={loading} onClick={pay}>Payer</button>
}4. Webhook : la seule source de vérité
SOAMTAR POST votre endpoint dès que le statut change. La requête est signée avec le header X-Soamtar-Signature.
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);
$data = $event['data'];
// Chaque mark*() ne fait rien si la commande n'est plus "pending" (les livraisons se répètent) ;
// markPaid() vérifie aussi que montant et devise correspondent à la commande avant d'expédier.
match ($event['event']) {
'payment.success' => Order::markPaid($data['merchant_reference'], $data['amount'], $data['currency']),
'payment.failed' => Order::markFailed($data['merchant_reference']),
'payment.cancelled' => Order::markCancelled($data['merchant_reference']),
default => null,
};
return response()->json(['received' => true]);
});Idempotence
Chaque événement est livré au moins une fois. Utilisez le couple event + data.id comme clé d'unicité dans votre base : si vous l'avez déjà traité, répondez 200 sans rien faire.
Détails complets, rotation de secret et format multi-version : voir Webhooks.
5. Sandbox vs production
| Sandbox | Production | |
|---|---|---|
| Clé | sk_test_ | sk_live_ |
| Base URL | https://app.so-amtar.com/v1 | identique |
| Connecteurs | Simulés (boutons Simuler succès / échec) | Réels, KYC requis |
| Montant débité | Aucun | Exact |
livemode dans les réponses | false | true |
Pour forcer un état en sandbox, ouvrez payment_url puis cliquez sur le bouton correspondant. Le webhook est déclenché comme en prod.
6. Checklist avant mise en production
- [ ] Le backend stocke
transaction.idetmerchant_refavant de répondre au navigateur. - [ ] Le navigateur ne décide jamais seul du statut de la commande : le webhook est la source de vérité.
- [ ] L'endpoint webhook vérifie
t,v1, la fenêtre de 5 min et répond2xxen < 5 s. - [ ]
return_urletcancel_urlaffichent le statut réel (depuis votre DB, alimentée par le webhook), pas une confirmation optimiste. - [ ] Les logs ne stockent jamais la clé API en clair.
- [ ] Si votre site a une CSP, elle autorise
script-src https://app.so-amtar.compour le SDK. - [ ] Test complet sandbox : succès, échec, annulation, expiration, double webhook.
Voir aussi la checklist d'intégration détaillée et Test → Production.
7. Questions fréquentes
Le SDK JS est-il obligatoire ? Non. Vous pouvez rediriger directement vers payment_url (window.location.href = paymentUrl). Le SDK ajoute les contrôles de clé et d'URL et le choix même onglet / nouvel onglet.
Puis-je appeler /v1/payments directement depuis le navigateur ? Non, jamais. Cela exposerait votre clé API. La route interne /checkout/soamtar est la seule façon sûre.
Combien de temps un payment_url est-il valide ? Jusqu'à expires_at, renvoyé à la création (2 heures par défaut sur la plateforme). Après expiration, SOAMTAR envoie payment.expired.
Puis-je avoir plusieurs paiements simultanés pour une même commande ? Oui, mais évitez-le. Utilisez merchant_ref unique par tentative (ex. ORDER-42-v2) pour garder un lien 1:1 côté réconciliation.