Skip to content

Intégrer SOAMTAR sur votre site ​

Ce guide vous montre comment encaisser Orange Money Sierra Leone sur votre site en trois étapes :

  1. Côté serveur : créer une transaction (une requête signée avec votre clé secrète).
  2. Côté navigateur : envoyer le payeur vers la page de paiement SOAMTAR avec le SDK JS.
  3. 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 sandbox
    • sk_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 :

  1. Vérifie l'utilisateur / le panier.
  2. Appelle POST https://app.so-amtar.com/v1/payments.
  3. Renvoie au navigateur payment_url et id.

Endpoint SOAMTAR ​

MéthodeURL
POSThttps://app.so-amtar.com/v1/payments

Headers :

HeaderValeur
AuthorizationBearer sk_test_ ou sk_live_
Content-Typeapplication/json

Body :

ChampTypeObligatoireDescription
merchant_refstringouiRéférence unique côté marchand (numéro de commande).
amountnumericouiMontant en leones, deux décimales max (ex. 150.00 = 150 SLE).
currencystringnonSLE par défaut.
providerstringnonorange_money_sl. Omettre pour passer par la page de paiement hébergée (Hosted Checkout).
payer.phonestringrecommandéFormat E.164 (+232 + 8 chiffres). Pré-remplit la page de paiement.
payer.namestringnonNom affiché sur le reçu.
return_urlstringrecommandéURL de retour après succès (alias de success_url).
cancel_urlstringrecommandéURL de retour en cas d'échec ou d'annulation (alias de failed_url).
callback_urlstringnonURL 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.
metadataobjectnonJusqu'à 50 clés libres, renvoyées dans le webhook.

Exemples ​

php
// 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');
ts
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 })
})
python
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 ​

json
{
  "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 ​

html
<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 = paymentUrl fait la même chose.

Bouton de paiement ​

html
<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 ​

ConstanteComportement
SoamtarSDK.REDIRECT_SELFMême onglet (défaut).
SoamtarSDK.REDIRECT_NEWNouvel 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) ​

tsx
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.

php
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 ​

SandboxProduction
Clésk_test_sk_live_
Base URLhttps://app.so-amtar.com/v1identique
ConnecteursSimulés (boutons Simuler succès / échec)Réels, KYC requis
Montant débitéAucunExact
livemode dans les réponsesfalsetrue

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.id et merchant_ref avant 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épond 2xx en < 5 s.
  • [ ] return_url et cancel_url affichent 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.com pour 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.

SOAMTAR Payment API Documentation.