Skip to content

Webhooks & IPN / APN ​

SOAMTAR notifies your server in real time through webhooks, also known as IPN (Instant Payment Notification) or APN (Automated Payment Notification).

  • IPN: instant notification as soon as the payment is confirmed (e.g. payment.success).
  • APN: automatic notifications for secondary state changes and lifecycle events (e.g. payment.expired).

Deliveries are signed (HMAC-SHA256), idempotent per event, retried with backoff, and the secret can be rotated without downtime.

Events ​

These are the only events you can subscribe to:

EventWhen
payment.successOrange Money Sierra Leone confirmed the payment (the customer entered their PIN)
payment.failedOrange Money refused the payment, returned an error or timed out
payment.cancelledThe payer cancelled
payment.expiredThe payment window elapsed without completion
payment.refundedRefunds now total the full amount of the payment
payment.partially_refundedA refund succeeded and part of the payment is still refundable
payout.successA payout to an Orange Money wallet was confirmed
payout.failedA payout failed
payout.cancelledA payout was cancelled
refund.successA refund was credited to the payer's Orange Money wallet
refund.failedA refund was refused or could not be completed (see failure_reason)
payout_request.submittedA withdrawal request to your bank / wallet was submitted
payout_request.approvedThe withdrawal request was approved
payout_request.rejectedThe withdrawal request was rejected
payout_request.completedThe withdrawal was paid out
payout_request.failedThe withdrawal failed

You choose the events each endpoint subscribes to (Dashboard → Webhooks, or subscribed_events on POST /v1/webhooks). When you omit subscribed_events, the SDKs subscribe to payment.success, payment.failed, payout.success and payout.failed.

There is one more event, webhook.test, sent by the dashboard test button and by POST /v1/webhooks/test. It is delivered to every active endpoint of the environment regardless of its subscriptions and cannot be subscribed to. Answer it with a 2xx like any other event.

Configuration ​

Dashboard → Developers → Webhooks → Add endpoint

  • URL: must be public HTTPS.
  • Events: a subset of the list above.
  • Secret: randomly generated. Keep it on your server.

An endpoint is suspended automatically after a number of consecutive failures (internal threshold).

Payload ​

json
{
  "event": "payment.success",
  "created": "2026-10-21T09:41:12+00:00",
  "data": {
    "id": "0e85b3c2-7c4f-4f1d-9b3a-1c8a2d4e5f6a",
    "reference": "TXN-1A2B3C4D-ABCDEF123456",
    "merchant_reference": "ORDER-001",
    "provider_reference": "CI190304.1139.A01071",
    "amount": "150.00",
    "fee_amount": "2.25",
    "net_amount": "147.75",
    "currency": "SLE",
    "status": "success",
    "is_test": false,
    "paid_at": "2026-10-21T09:41:10+00:00",
    "metadata": { "cart_id": "abc-123" }
  }
}

data.id is the SOAMTAR transaction id; data.merchant_reference is the merchant_ref you sent.

Refund events ​

Each refund produces its own set of events (a payment can carry several partial refunds):

  • a successful refund sends refund.success and payment.partially_refunded (or payment.refunded once refunds total the payment);
  • a refused refund sends refund.failed only.

For refund.success / refund.failed, data is the refund object plus transaction_reference, merchant_reference and is_test:

json
{
  "event": "refund.success",
  "created": "2026-10-22T10:05:31+00:00",
  "data": {
    "id": "9b1f4c2e-5d6a-4e8b-a1c3-2f4e6a8b0c1d",
    "object": "refund",
    "reference": "REF-7K2M9Q4XW8T1B3NC",
    "transaction_id": "0e85b3c2-7c4f-4f1d-9b3a-1c8a2d4e5f6a",
    "amount": "40.00",
    "currency": "SLE",
    "reason": "customer_request",
    "description": null,
    "status": "success",
    "fee_refunded": false,
    "fee_refunded_amount": "0.60",
    "tax_refunded_amount": "0.00",
    "failure_reason": null,
    "processed_at": "2026-10-22T10:05:30+00:00",
    "failed_at": null,
    "created_at": "2026-10-22T10:04:58+00:00",
    "updated_at": "2026-10-22T10:05:30+00:00",
    "transaction_reference": "TXN-1A2B3C4D-ABCDEF123456",
    "merchant_reference": "ORDER-001",
    "is_test": false
  }
}

For payment.refunded / payment.partially_refunded, data is the usual transaction payload (as above) with an extra data.refund object holding the refund that triggered the event.

Signature ​

Each request carries the header:

X-Soamtar-Signature: t=<unix>,v1=<hex_hmac_sha256>

During a secret rotation, two signatures can coexist:

X-Soamtar-Signature: t=<unix>,v1=<hmac_current_secret>,v2=<hmac_next_secret>

Verification ​

  1. Parse the timestamp t and the signature v1 (and v2 if present).
  2. Check that t is recent (within the tolerance window).
  3. Recompute expected = HMAC-SHA256(t + "." + raw_body, secret).
  4. Compare in constant time (hash_equals in PHP, crypto.timingSafeEqual in Node).
  5. If a v2 is present, accept when either v1 OR v2 matches.
php
$header = $_SERVER['HTTP_X_SOAMTAR_SIGNATURE'] ?? '';
$body = file_get_contents('php://input');
$secrets = [getenv('SOAMTAR_WEBHOOK_SECRET'), getenv('SOAMTAR_WEBHOOK_SECRET_NEXT')];

if (! preg_match('/t=(\d+)/', $header, $tm)) throw new RuntimeException('bad sig');
$ts = (int) $tm[1];
if (abs(time() - $ts) > 300) throw new RuntimeException('expired');

$ok = false;
foreach (['v1', 'v2'] as $i => $version) {
    if (! preg_match("/{$version}=([a-f0-9]{64})/", $header, $vm)) continue;
    $secret = $secrets[$i] ?? null;
    if ($secret === null || $secret === '') continue;
    if (hash_equals(hash_hmac('sha256', "{$ts}.{$body}", $secret), $vm[1])) {
        $ok = true; break;
    }
}
if (! $ok) throw new RuntimeException('invalid');

Tolerance

The example uses 300 seconds, which is a sensible ceiling and matches the tolerance SOAMTAR applies.

Additional headers ​

HeaderMeaning
X-Soamtar-TimestampSigning timestamp (same value as t)
X-Soamtar-EventEvent type (payment.success, refund.success, webhook.test, ...)
X-Soamtar-DeliveryDelivery attempt identifier — useful in logs
User-AgentSoamtar-Webhooks/1.0 (+https://app.so-amtar.com/docs/webhooks)

Idempotency on your server ​

The same event can arrive more than once (retry after a timeout, replay after an incident). De-duplicate on the pair (event, data.id) and ignore duplicates. For payment.refunded / payment.partially_refunded, add data.refund.id to the key: one payment can emit payment.partially_refunded several times, once per refund.

sql
CREATE UNIQUE INDEX uq_inbound_event ON your_webhook_log(event, object_id, refund_id);

Retries and delivery ​

  • At most 5 attempts: the first delivery, then retries after 60 s, 5 min, 30 min and 2 h.
  • 2xx = success, SOAMTAR stops.
  • 3xx = treated as a failure (your endpoint must answer 2xx directly).
  • 4xx, 5xx, timeout = failure, retry scheduled.
  • After a number of consecutive failures, the endpoint is suspended and you receive an email.

You can resend a delivery manually from Dashboard → Webhook logs → Resend.

Per-transaction callback_url ​

POST /v1/payments and POST /v1/payouts accept an optional callback_url (HTTPS). SOAMTAR sends every event of that transaction to it, in addition to your registered endpoints:

  • same payload ({event, created, data}), same headers;
  • same X-Soamtar-Signature, signed with the secret of your oldest active webhook endpoint in the same environment (test or live). You therefore need at least one registered endpoint to receive callbacks: without any endpoint there is no secret to sign with, and the callback is skipped;
  • same retries (60 s, 5 min, 30 min, 2 h);
  • not sent twice: if callback_url equals the URL of one of your registered endpoints, only the endpoint delivery happens.

The callback receives the transaction's events whatever the subscriptions of your endpoints (refund events included), so verify the signature and de-duplicate exactly as for an endpoint.

Zero-downtime secret rotation ​

  1. Dashboard → Webhooks → Rotate secret. SOAMTAR shows the new secret (copy it now — it is never shown again).
  2. During the rotation window, each payload can be signed with both secrets.
  3. Update your verifier to accept v1 OR v2.
  4. Click Promote: the new secret becomes current and the old one is invalidated.

Testing a webhook ​

Send a webhook.test event signed with your real secret from the dashboard (Dashboard → Webhooks → Test) or with POST /v1/webhooks/test and your secret key. It goes to every active endpoint of the key's environment, whatever their subscriptions. If you have no active endpoint, the API answers 400 no_webhook_endpoints.

bash
curl -X POST https://app.so-amtar.com/v1/webhooks/test \
  -H "Authorization: Bearer sk_test_..."
json
{
  "message": "Test webhooks dispatched to 1 endpoint(s).",
  "event_type": "webhook.test",
  "payload": { "event": "webhook.test", "created": "...", "data": { "id": "evt_test_...", "reference": "TXN-TEST-12345", "status": "success", "is_test": true } }
}

If you miss a webhook ​

  1. Pull endpoint: POST /v1/payments/{id}/verify makes SOAMTAR query Orange Money and emit the event again if the transition happened.
  2. Automatic internal reconciliation catches stuck transactions: usually you have nothing to do.

SOAMTAR Payment API Documentation.