SDK PHP / Laravel
Official PHP client for the SOAMTAR API: collect payments and send payouts with Orange Money Sierra Leone, and verify webhooks.
- Package:
soamtar/soamtar-php(version 1.1.0), namespaceSoamtar\. - PHP 8.2+ with
ext-curlandext-json. No other runtime dependency. - Currency
SLEby default (USD and EUR also accepted), amounts as 2-decimal strings ("150.00"), phones in E.164 (+232+ 8 digits), providerorange_money_sl.
Not yet on Packagist
Until the package is published, install it from the SDK repository (Composer vcs or path repository).
Installation
composer require soamtar/soamtar-phpConfiguration
Use your secret key (sk_test_… or sk_live_…) on the server only. Test keys go through the simulation on the same host, no real money moves.
use Soamtar\Soamtar;
Soamtar::configure(getenv('SOAMTAR_SECRET_KEY')); // or Soamtar::configure() to read SOAMTAR_SECRET_KEYOr create a client explicitly (useful for dependency injection):
use Soamtar\SoamtarClient;
$soamtar = new SoamtarClient(getenv('SOAMTAR_SECRET_KEY'), [
'base_url' => 'https://app.so-amtar.com/v1', // default; also read from SOAMTAR_API_URL, then SOAMTAR_BASE_URL
'timeout' => 30, // seconds
'connect_timeout' => 10,
'sign_requests' => false, // optional request signing, see below
]);
$soamtar->payments->create([...]);| Option | Default | Notes |
|---|---|---|
base_url | https://app.so-amtar.com/v1 | HTTPS only (plain HTTP allowed on localhost). Falls back to SOAMTAR_API_URL, then SOAMTAR_BASE_URL (deprecated name). |
timeout | 30 | Total request timeout, in seconds. |
connect_timeout | 10 | Connection timeout, in seconds. |
sign_requests | false | Adds Soamtar-Timestamp / Soamtar-Signature to every POST (see Request signing). |
transport | cURL | callable(string $method, string $url, array $headers, ?string $body): array{status: int, body: string}, for tests or a custom HTTP stack. |
Every POST carries an Idempotency-Key (UUID v4). Pass your own as the last argument of create() so a retry never charges twice.
Request signing
With 'sign_requests' => true, every POST also carries:
Soamtar-Timestamp: <unix seconds>
Soamtar-Signature: v1=hex(HMAC_SHA256(secretKey, "<timestamp>.<METHOD>.<path>"))<path> is the URL path without the leading slash (for example v1/payments); the timestamp must be within ±300 seconds of the server clock. It is optional hardening: the Bearer key over HTTPS is enough on its own. $client->signatureHeaders('POST', $url) returns the two headers if you build requests yourself. See Authentication.
Collect a payment
Direct push to the customer's phone (they confirm with their Orange Money PIN):
$payment = Soamtar::payments()->create([
'merchant_ref' => 'ORDER-1001',
'amount' => '150.00', // Le 150.00
'provider' => 'orange_money_sl',
'phone' => '+23276123456',
'description' => 'Order #1001',
'callback_url' => 'https://shop.example.sl/soamtar/webhook', // optional
], 'order-1001'); // Idempotency-Key
$payment['data']['status']; // "pending" until the customer enters their PIN
$payment['data']['reference']; // SOAMTAR reference, e.g. TXN-…Hosted checkout (SOAMTAR collects the phone number on its own page):
Soamtar::payments()->create([
'merchant_ref' => 'ORDER-1002',
'amount' => '75.50',
'success_url' => 'https://shop.example.sl/thanks',
'failed_url' => 'https://shop.example.sl/cart',
])->redirect(); // 302 to data.payment_urlcurrency defaults to SLE. return_url / cancel_url are still accepted by the API as aliases of success_url / failed_url.
callback_url (optional, HTTPS) is a per-payment webhook URL: SOAMTAR posts every event of this payment to it, with the same payload and X-Soamtar-Signature as to your registered endpoints, so verify it with Webhook::constructEvent() the same way. It is signed with the secret of your oldest active endpoint, so register at least one endpoint. Payouts accept it too.
Check a payment:
Soamtar::payments()->retrieve($id); // SOAMTAR id or reference
Soamtar::payments()->verify($id); // asks Orange Money for the latest status
Soamtar::payments()->status($idOrReference); // data.id or data.reference (not your merchant_ref)
Soamtar::payments()->list(['status' => 'success', 'per_page' => 20]);Send a payout
$payout = Soamtar::payouts()->create([
'merchant_ref' => 'PAYOUT-042',
'amount' => '250.00',
'recipient' => ['phone' => '+23276123456', 'name' => 'Aminata Kamara'],
'description' => 'Agent commission October',
]);provider defaults to orange_money_sl and currency to SLE. The key needs the can_payout scope. The final result arrives with the payout.success / payout.failed webhooks.
Refund a payment
Refund a successful payment, fully or partially: SOAMTAR credits the amount back to the payer's Orange Money number and debits your balance. The key needs the can_refund scope.
$refund = Soamtar::refunds()->create([
'transaction_id' => $payment['data']['id'], // SOAMTAR id (or reference) of the payment
'amount' => '50.00', // omit for a full refund of what remains
'reason' => 'customer_request',
'description' => 'Item out of stock',
], 'refund-order-1001'); // Idempotency-Key
$refund['data']['reference']; // "REF-…"
$refund['data']['status']; // "success" (test key), "processing" (Orange Money) or "failed"
Soamtar::refunds()->retrieve($refund['data']['reference']); // id or reference
Soamtar::refunds()->list(['transaction_id' => $payment['data']['id'], 'status' => 'success']);The refund is submitted in the same request; with Orange Money it stays processing until Orange confirms the credit. The outcome arrives with the refund.success / refund.failed webhooks, plus payment.refunded / payment.partially_refunded on success. See Refunds for the object and error codes.
Webhooks
Notifications are sent to the webhook endpoints you register (dashboard or webhooks()->create()), signed with that endpoint's secret. Each delivery carries X-Soamtar-Signature: t=<unix>,v1=<hex> (plus v2= while the secret is being rotated). The signature is HMAC-SHA256(secret, "<t>.<raw body>"), accepted for 300 seconds.
use Soamtar\Exceptions\SignatureException;
use Soamtar\Webhook;
try {
$event = Webhook::constructEvent(
file_get_contents('php://input'), // RAW body
$_SERVER['HTTP_X_SOAMTAR_SIGNATURE'] ?? '',
getenv('SOAMTAR_WEBHOOK_SECRET'),
);
} catch (SignatureException $e) {
http_response_code(400);
exit;
}
match ($event['event']) {
'payment.success' => markOrderPaid($event['data']['merchant_reference']),
'payment.failed', 'payment.cancelled', 'payment.expired' => markOrderFailed($event['data']['merchant_reference']),
'refund.success' => markOrderRefunded($event['data']['merchant_reference'], $event['data']['amount']),
default => null, // webhook.test and anything else: just answer 2xx
};
http_response_code(200);Deliveries can be retried: make your handler idempotent on data.id.
Events you can subscribe to: payment.success, payment.failed, payment.cancelled, payment.expired, payment.refunded, payment.partially_refunded, payout.success, payout.failed, payout.cancelled, refund.success, refund.failed, payout_request.submitted, payout_request.approved, payout_request.rejected, payout_request.completed, payout_request.failed. The test event webhook.test (from sendTest() or the dashboard) is sent whatever your subscriptions and only needs a 2xx. See Webhooks.
Without a stored secret (AutoVerifier)
If you don't keep SOAMTAR_WEBHOOK_SECRET, AutoVerifier fetches the endpoint secret with your secret key (scope can_manage_webhooks) and caches it:
use Soamtar\SoamtarClient;
use Soamtar\Webhook\AutoVerifier;
use Soamtar\Webhook\FilesystemWebhookSecretCache;
$verifier = new AutoVerifier(
new SoamtarClient(getenv('SOAMTAR_SECRET_KEY')),
new FilesystemWebhookSecretCache(__DIR__.'/storage/soamtar-cache'),
);
$event = $verifier->constructEvent($payload, $signatureHeader, getenv('SOAMTAR_WEBHOOK_ENDPOINT_ID'));Manage endpoints
Soamtar::webhooks()->create(['url' => 'https://shop.example.sl/soamtar/webhook']); // idempotent by URL
Soamtar::webhooks()->list();
Soamtar::webhooks()->lookup('https://shop.example.sl/soamtar/webhook');
Soamtar::webhooks()->secret($endpointId);
Soamtar::webhooks()->sendTest(); // sends webhook.test to every active endpointcreate() subscribes to payment.success, payment.failed, payout.success and payout.failed unless you pass subscribed_events.
Deprecated aliases, still working: webhooks()->all() (use list()), webhooks()->revealSecret() (use secret()), balance()->show() (use retrieve()).
Laravel
Bind the client once, in AppServiceProvider::register():
use Soamtar\SoamtarClient;
$this->app->singleton(SoamtarClient::class, fn () => new SoamtarClient(config('services.soamtar.secret')));// config/services.php
'soamtar' => [
'secret' => env('SOAMTAR_SECRET_KEY'),
'webhook_secret' => env('SOAMTAR_WEBHOOK_SECRET'),
],Controller:
use Illuminate\Http\Request;
use Soamtar\Exceptions\SignatureException;
use Soamtar\SoamtarClient;
use Soamtar\Webhook;
final class CheckoutController
{
public function __construct(private readonly SoamtarClient $soamtar) {}
public function pay(Order $order)
{
$payment = $this->soamtar->payments->create([
'merchant_ref' => $order->reference,
'amount' => number_format($order->total, 2, '.', ''),
'success_url' => route('orders.thanks', $order),
'failed_url' => route('orders.show', $order),
], 'order-'.$order->id);
return redirect()->away($payment['data']['payment_url']);
}
public function webhook(Request $request)
{
try {
$event = Webhook::constructEvent(
$request->getContent(),
(string) $request->header('X-Soamtar-Signature'),
config('services.soamtar.webhook_secret'),
);
} catch (SignatureException) {
abort(400);
}
if ($event['event'] === 'payment.success') {
Order::where('reference', $event['data']['merchant_reference'])->first()?->markPaid();
}
return response()->noContent();
}
}Exclude the webhook route from CSRF in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: ['soamtar/webhook']);
})Errors
| Exception | When |
|---|---|
Soamtar\Exceptions\ApiException | HTTP status ≥ 400. getHttpStatus(), getErrorCode(), getRawResponse() (secrets masked), isRetryable() (429 and 5xx). |
Soamtar\Exceptions\SignatureException | Invalid, expired or malformed webhook signature. |
Soamtar\Exceptions\SoamtarException | Base class: configuration, network or non-JSON response. |
use Soamtar\Exceptions\ApiException;
try {
Soamtar::payouts()->create($params, $idempotencyKey);
} catch (ApiException $e) {
if ($e->isRetryable()) {
// retry later with the SAME idempotency key
}
error_log($e->getErrorCode().': '.$e->getMessage());
}Reference
| Facade | Methods |
|---|---|
Soamtar::payments() | create(array $params, string $idempotencyKey = ''), retrieve($id), list(array $params = []), status($idOrReference), verify($id), listSplits($transactionId), createSplit($transactionId, array $params, string $idempotencyKey = '') |
Soamtar::payouts() | create(array $params, string $idempotencyKey = ''), retrieve($id), list(array $params = []) |
Soamtar::refunds() | create(array $params, string $idempotencyKey = ''), retrieve($idOrReference), list(array $params = []) |
Soamtar::payoutRequests() | create(array $params, string $idempotencyKey = ''), list(array $params = []), retrieve($id) |
Soamtar::subscriptions() | create(['plan_id' => …, 'customer_id' => …]), retrieve($id), list(array $params = []), cancel($id) |
Soamtar::webhooks() | list(), create(array $payload), lookup(string $url), secret($endpointId), sendTest() |
Soamtar::balance() | retrieve() |
Soamtar::providers() | all() |
Soamtar::status() | retrieve(string $reference) (same as payments()->status()) |
Soamtar::disputes() | list(array $params = []), retrieve($id), respond($id, array $params, string $idempotencyKey = '') |
Soamtar::settlements() | list(array $params = []), retrieve($id) |
Soamtar::health() | GET /v1/health |
The same resources are available as properties on a SoamtarClient instance ($soamtar->payments, $soamtar->payouts, …). Every method returns a Soamtar\SoamtarResponse (array access, get(), toArray(), successful(), redirect()).
Testing your webhook handler
Webhook::sign() builds a valid header, handy in Pest or PHPUnit:
use Soamtar\Webhook;
it('accepts a signed payment.success', function () {
config()->set('services.soamtar.webhook_secret', 'whsec_test');
$payload = json_encode(['event' => 'payment.success', 'data' => ['id' => 'tx_1', 'merchant_reference' => 'ORDER-1001']]);
$this->call('POST', '/soamtar/webhook', [], [], [], [
'HTTP_X_SOAMTAR_SIGNATURE' => Webhook::sign($payload, 'whsec_test'),
'CONTENT_TYPE' => 'application/json',
], $payload)->assertNoContent();
});