Skip to content

Integrate SOAMTAR on your website ​

This guide shows how to accept Orange Money Sierra Leone payments on your site in three steps:

  1. Server side: create a transaction (a request authenticated with your secret key).
  2. Browser side: open the SOAMTAR payment window with the JS SDK.
  3. 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 sandbox
    • sk_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:

  1. Checks the user and the cart.
  2. Calls POST https://app.so-amtar.com/v1/payments.
  3. Returns payment_url and id to the browser.

SOAMTAR endpoint ​

MethodURL
POSThttps://app.so-amtar.com/v1/payments

Headers:

HeaderValue
AuthorizationBearer sk_test_… or sk_live_…
Content-Typeapplication/json

Body:

FieldTypeRequiredDescription
merchant_refstringyesYour unique reference (order number).
amountnumericyesAmount in leones, at most two decimals (e.g. 150.00 = Le 150.00).
currencystringnoSLE by default.
providerstringnoorange_money_sl. Omit it to use the SOAMTAR payment window (Hosted Checkout).
payer.phonestringrecommendedE.164 format (+232 followed by 8 digits). Pre-fills the payment window.
payer.namestringnoName shown on the receipt.
return_urlstringrecommendedWhere the customer lands after a successful payment (alias of success_url).
cancel_urlstringrecommendedWhere the customer lands after a failure or cancellation (alias of failed_url).
callback_urlstringnoHTTPS URL that receives every webhook event of this payment, signed like your endpoints (requires at least one registered webhook endpoint). See Webhooks.
metadataobjectnoUp to 50 free keys, sent back in the webhook.

Examples ​

php
// 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');
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)

  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 })
})
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)

    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 ​

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
}

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 ​

html
<script src="https://app.so-amtar.com/sdk/soamtar.js" defer></script>

Served by SOAMTAR with a one-day cache.

Simple integration: popup ​

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

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

ConstantBehaviour
SOAMTAR.OPEN_IN_POPUPModal centred above the page (default).
SOAMTAR.OPEN_IN_NEW_TABOpens a new tab (fallback when popups are blocked).
SOAMTAR.OPEN_IN_SAME_TABFull-page redirect to payment_url.
SOAMTAR.EMBEDInline in a <div id="soamtar-frame"></div> you provide.
SOAMTAR.HEADLESSOpens nothing and returns paymentUrl: you display it yourself.

Callbacks ​

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

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

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

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);
    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 ​

SandboxProduction
Keysk_test_…sk_live_…
Base URLhttps://app.so-amtar.com/v1same
Orange MoneySimulated (Simulate success / failure buttons)Real, KYC required
Money movedNoneExact amount
livemode in responsesfalsetrue

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.id and merchant_ref before 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, v1 and the 5-minute window, and replies 2xx in under 5 s.
  • [ ] return_url and cancel_url show 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.

SOAMTAR Payment API Documentation.