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:
- En el servidor: crear una transacción (una solicitud autenticada con su clave secreta).
- En el navegador: abrir la ventana de pago de SOAMTAR con el SDK JS.
- 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 sandboxsk_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:
- Comprueba el usuario y el carrito.
- Llama a
POST https://app.so-amtar.com/v1/payments. - Devuelve
payment_urleidal navegador.
Endpoint de SOAMTAR
| Método | URL |
|---|---|
POST | https://app.so-amtar.com/v1/payments |
Cabeceras:
| Cabecera | Valor |
|---|---|
Authorization | Bearer sk_test_… o sk_live_… |
Content-Type | application/json |
Cuerpo:
| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
merchant_ref | string | sí | Su referencia única (número de pedido). |
amount | numeric | sí | Importe en leones, con dos decimales como máximo (p. ej. 150.00 = Le 150.00). |
currency | string | no | SLE por defecto. |
provider | string | no | orange_money_sl. Omítalo para usar la ventana de pago de SOAMTAR (Hosted Checkout). |
payer.phone | string | recomendado | Formato E.164 (+232 seguido de 8 cifras). Rellena previamente la ventana de pago. |
payer.name | string | no | Nombre mostrado en el recibo. |
return_url | string | recomendado | Página a la que llega el cliente tras un pago correcto (alias de success_url). |
cancel_url | string | recomendado | Página a la que llega el cliente tras un fallo o una cancelación (alias de failed_url). |
metadata | object | no | Hasta 50 claves libres, devueltas en el webhook. |
Ejemplos
// 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"]}Respuesta de 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
}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
<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
<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:
<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
| Constante | Comportamiento |
|---|---|
SOAMTAR.OPEN_IN_POPUP | Ventana modal centrada sobre la página (por defecto). |
SOAMTAR.OPEN_IN_NEW_TAB | Abre una pestaña nueva (alternativa si se bloquean las ventanas emergentes). |
SOAMTAR.OPEN_IN_SAME_TAB | Redirección de página completa a payment_url. |
SOAMTAR.EMBED | Integrada en un <div id="soamtar-frame"></div> que usted proporciona. |
SOAMTAR.HEADLESS | No abre nada y devuelve paymentUrl: usted lo muestra. |
Callbacks
| Opción | Firma | Cuá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
<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: 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>).
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
| Sandbox | Producción | |
|---|---|---|
| Clave | sk_test_… | sk_live_… |
| URL base | https://app.so-amtar.com/v1 | la misma |
| Orange Money | Simulado (botones Simular éxito / fallo) | Real, KYC obligatorio |
| Dinero movido | Ninguno | Importe exacto |
livemode en las respuestas | false | true |
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.idymerchant_refantes 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,v1y la ventana de 5 minutos, y responde2xxen menos de 5 s. - [ ]
return_urlycancel_urlmuestran 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.