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:
| Event | When |
|---|---|
payment.success | Orange Money Sierra Leone confirmed the payment (the customer entered their PIN) |
payment.failed | Orange Money refused the payment, returned an error or timed out |
payment.cancelled | The payer cancelled |
payment.expired | The payment window elapsed without completion |
payment.refunded | Refunds now total the full amount of the payment |
payment.partially_refunded | A refund succeeded and part of the payment is still refundable |
payout.success | A payout to an Orange Money wallet was confirmed |
payout.failed | A payout failed |
payout.cancelled | A payout was cancelled |
refund.success | A refund was credited to the payer's Orange Money wallet |
refund.failed | A refund was refused or could not be completed (see failure_reason) |
payout_request.submitted | A withdrawal request to your bank / wallet was submitted |
payout_request.approved | The withdrawal request was approved |
payout_request.rejected | The withdrawal request was rejected |
payout_request.completed | The withdrawal was paid out |
payout_request.failed | The 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
{
"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.successandpayment.partially_refunded(orpayment.refundedonce refunds total the payment); - a refused refund sends
refund.failedonly.
For refund.success / refund.failed, data is the refund object plus transaction_reference, merchant_reference and is_test:
{
"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
- Parse the timestamp
tand the signaturev1(andv2if present). - Check that
tis recent (within the tolerance window). - Recompute
expected = HMAC-SHA256(t + "." + raw_body, secret). - Compare in constant time (
hash_equalsin PHP,crypto.timingSafeEqualin Node). - If a
v2is present, accept when either v1 OR v2 matches.
$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
| Header | Meaning |
|---|---|
X-Soamtar-Timestamp | Signing timestamp (same value as t) |
X-Soamtar-Event | Event type (payment.success, refund.success, webhook.test, ...) |
X-Soamtar-Delivery | Delivery attempt identifier — useful in logs |
User-Agent | Soamtar-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.
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_urlequals 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
- Dashboard → Webhooks → Rotate secret. SOAMTAR shows the new secret (copy it now — it is never shown again).
- During the rotation window, each payload can be signed with both secrets.
- Update your verifier to accept
v1ORv2. - 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.
curl -X POST https://app.so-amtar.com/v1/webhooks/test \
-H "Authorization: Bearer sk_test_..."{
"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
- Pull endpoint:
POST /v1/payments/{id}/verifymakes SOAMTAR query Orange Money and emit the event again if the transition happened. - Automatic internal reconciliation catches stuck transactions: usually you have nothing to do.