Skip to content

Integrar SOAMTAR en su sitio web ​

Esta guía muestra cómo aceptar pagos con Orange Money Sierra Leona en su sitio en tres pasos:

  1. En el servidor: crear una transacción (una solicitud autenticada con su clave secreta).
  2. En el navegador: abrir la ventana de pago de SOAMTAR con el SDK JS.
  3. En el servidor: recibir y verificar el webhook final.

Arquitectura recomendada: la ventana JS nunca habla con SOAMTAR usando su clave secreta. Llama a una ruta de su backend, que es quien llama a SOAMTAR. Es la única forma de mantener sk_live_… fuera del navegador.

Visión general del flujo ​

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. Requisitos previos ​

  • Una cuenta de comercio en https://app.so-amtar.com (KYC aprobado para el modo live).
  • Sus claves API, en Desarrolladores → Claves API:
    • sk_test_… para el sandbox
    • sk_live_… para producción
  • Un endpoint HTTPS público para los webhooks (el modo de prueba acepta túneles ngrok / cloudflared).

Nunca exponga sk_live_ en el navegador

Toda clave API de SOAMTAR es secreta. La ventana JS nunca la necesita: llama a su backend, que hace la solicitud de servidor a servidor.

2. Backend: crear una transacción ​

Su backend expone una única ruta interna (por ejemplo POST /checkout/soamtar) a la que llama el navegador. Esta ruta:

  1. Comprueba el usuario y el carrito.
  2. Llama a POST https://app.so-amtar.com/v1/payments.
  3. Devuelve payment_url e id al navegador.

Endpoint de SOAMTAR ​

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

Cabeceras:

CabeceraValor
AuthorizationBearer sk_test_… o sk_live_…
Content-Typeapplication/json

Cuerpo:

CampoTipoObligatorioDescripción
merchant_refstringsíSu referencia única (número de pedido).
amountnumericsíImporte en leones, con dos decimales como máximo (p. ej. 150.00 = Le 150.00).
currencystringnoSLE por defecto.
providerstringnoorange_money_sl. Omítalo para usar la ventana de pago de SOAMTAR (Hosted Checkout).
payer.phonestringrecomendadoFormato E.164 (+232 seguido de 8 cifras). Rellena previamente la ventana de pago.
payer.namestringnoNombre mostrado en el recibo.
return_urlstringrecomendadoPágina a la que llega el cliente tras un pago correcto (alias de success_url).
cancel_urlstringrecomendadoPágina a la que llega el cliente tras un fallo o una cancelación (alias de failed_url).
metadataobjectnoHasta 50 claves libres, devueltas en el webhook.

Ejemplos ​

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"]}

Respuesta de 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
}

Su backend debe guardar data.id y data.reference y asociarlos a su order_id antes de responder al navegador. Esa tabla es la que usará después para conciliar el webhook.

3. Frontend: la ventana de pago de SOAMTAR ​

El SDK JS es un script sin dependencias. Abre payment_url en una ventana modal, escucha los eventos de pago y le avisa cuando el pago termina.

Incluir el script ​

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

Servido por SOAMTAR con una caché de un día.

Integración sencilla: ventana emergente ​

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>

Integración declarativa: atributos data-* ​

Para un sitio estático o un CMS, no hace falta JavaScript propio:

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>

El SDK detecta [data-soamtar] al cargar, llama a la URL indicada con POST, lee paymentUrl en la respuesta JSON y abre la ventana.

Modos de presentación ​

ConstanteComportamiento
SOAMTAR.OPEN_IN_POPUPVentana modal centrada sobre la página (por defecto).
SOAMTAR.OPEN_IN_NEW_TABAbre una pestaña nueva (alternativa si se bloquean las ventanas emergentes).
SOAMTAR.OPEN_IN_SAME_TABRedirección de página completa a payment_url.
SOAMTAR.EMBEDIntegrada en un <div id="soamtar-frame"></div> que usted proporciona.
SOAMTAR.HEADLESSNo abre nada y devuelve paymentUrl: usted lo muestra.

Callbacks ​

OpciónFirmaCuándo
didReceivePayment({ status, transactionId, reference })La ventana recibió un evento final.
didCancel()El cliente cerró la ventana o hizo clic en «cancelar».
didReceiveError(error)Error de red, token caducado, CSP…
didClose()Se llama siempre al cerrarse la ventana (después de los demás).

Evento ≠ prueba de pago

Los callbacks JS son informativos (el cliente puede cerrar la pestaña antes de tiempo, perder la cobertura, etc.). Nunca marque un pedido como pagado desde el navegador: solo el webhook es fiable.

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: la única fuente de verdad ​

SOAMTAR envía un POST a su endpoint en cuanto cambia el estado. La solicitud va firmada en la cabecera X-Soamtar-Signature (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]);
});

Idempotencia

Cada evento se entrega al menos una vez. Use el par event + data.id como clave única en su base de datos: si ya lo procesó, responda 200 y no haga nada.

Detalles completos, rotación del secreto y formatos versionados: vea Webhooks.

5. Sandbox frente a producción ​

SandboxProducción
Clavesk_test_…sk_live_…
URL basehttps://app.so-amtar.com/v1la misma
Orange MoneySimulado (botones Simular éxito / fallo)Real, KYC obligatorio
Dinero movidoNingunoImporte exacto
livemode en las respuestasfalsetrue

Para forzar un estado en sandbox, abra payment_url en la ventana y haga clic en el botón correspondiente. El webhook se envía exactamente igual que en producción.

6. Lista de comprobación antes de pasar a producción ​

  • [ ] Su backend guarda transaction.id y merchant_ref antes de responder al navegador.
  • [ ] La ventana nunca decide por sí sola el estado del pedido: el webhook es la fuente de verdad.
  • [ ] Su endpoint de webhook comprueba t, v1 y la ventana de 5 minutos, y responde 2xx en menos de 5 s.
  • [ ] return_url y cancel_url muestran el estado real (desde su base de datos, alimentada por el webhook), no una confirmación optimista.
  • [ ] Los registros nunca guardan la clave API en claro.
  • [ ] Su CSP autoriza https://app.so-amtar.com (script-src, frame-src, connect-src) si usa la ventana.
  • [ ] Prueba completa en sandbox: éxito, fallo, cancelación, caducidad, webhook duplicado.

Vea también la lista de integración detallada y De prueba a producción (en inglés).

7. Preguntas frecuentes ​

¿Es obligatoria la ventana emergente? No. Puede redirigir directamente a payment_url (window.location.href = paymentUrl). La ventana ofrece una mejor experiencia porque el cliente se queda en su página.

¿Puedo llamar a /v1/payments directamente desde el navegador? No, nunca. Expondría su clave API. La ruta interna /checkout/soamtar es la única forma segura.

¿Cuánto tiempo es válida una payment_url? Hasta expires_at, devuelto al crearla (2 horas por defecto en la plataforma). Después, SOAMTAR envía payment.expired.

¿Puedo tener varios pagos simultáneos para un mismo pedido? Sí, pero evítelo. Use un merchant_ref único por intento (p. ej. ORDER-42-v2) para mantener una relación 1:1 en la conciliación.

¿Qué ve el cliente en su teléfono? Una solicitud de Orange Money de su empresa por el importe exacto en leones. La aprueba con su PIN Orange Money; no tiene que instalar nada más.

SOAMTAR Payment API Documentation.