Webhooks & IPN / APN
SOAMTAR notifie votre serveur en temps réel par webhooks, aussi appelés IPN (Instant Payment Notification) ou APN (Automated Payment Notification).
- IPN : notification instantanée dès que le paiement est confirmé (ex.
payment.success). - APN : notifications automatiques pour les changements d'état secondaires et le cycle de vie (ex.
payment.expired).
La livraison est signée (HMAC-SHA256), idempotente par événement, retentée avec backoff, et le secret peut être changé sans coupure.
Événements
Voici les seuls événements auxquels vous pouvez vous abonner :
| Événement | Quand |
|---|---|
payment.success | Orange Money Sierra Leone a confirmé le paiement (le client a saisi son code PIN) |
payment.failed | Orange Money a refusé le paiement, renvoyé une erreur ou expiré |
payment.cancelled | Le payeur a annulé |
payment.expired | La fenêtre de paiement s'est écoulée sans finalisation |
payment.refunded | Les remboursements couvrent désormais la totalité du paiement |
payment.partially_refunded | Un remboursement a réussi et une partie du paiement reste remboursable |
payout.success | Un paiement sortant vers un compte Orange Money a été confirmé |
payout.failed | Un paiement sortant a échoué |
payout.cancelled | Un paiement sortant a été annulé |
refund.success | Un remboursement a été crédité sur le compte Orange Money du payeur |
refund.failed | Un remboursement a été refusé ou n'a pas pu aboutir (voir failure_reason) |
payout_request.submitted | Une demande de retrait vers votre banque / wallet a été soumise |
payout_request.approved | La demande de retrait a été approuvée |
payout_request.rejected | La demande de retrait a été rejetée |
payout_request.completed | Le retrait a été versé |
payout_request.failed | Le retrait a échoué |
Vous choisissez les événements de chaque endpoint (Dashboard → Webhooks, ou subscribed_events sur POST /v1/webhooks). Si vous omettez subscribed_events, les SDK abonnent l'endpoint à payment.success, payment.failed, payout.success et payout.failed.
Il existe un événement de plus, webhook.test, envoyé par le bouton de test du dashboard et par POST /v1/webhooks/test. Il est livré à tous les endpoints actifs de l'environnement quels que soient leurs abonnements, et on ne peut pas s'y abonner. Répondez-y par un 2xx comme pour tout autre événement.
Configuration
Dashboard → Développeurs → Webhooks → Add endpoint
- URL : HTTPS publique obligatoire.
- Événements : sous-ensemble de la liste ci-dessus.
- Secret : généré aléatoirement. À conserver côté serveur.
Un endpoint est suspendu automatiquement après un certain nombre d'échecs consécutifs (seuil interne).
Contenu
{
"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 est l'identifiant SOAMTAR de la transaction ; data.merchant_reference est le merchant_ref que vous avez envoyé.
Événements de remboursement
Chaque remboursement produit son propre jeu d'événements (un paiement peut porter plusieurs remboursements partiels) :
- un remboursement réussi envoie
refund.successetpayment.partially_refunded(oupayment.refundedquand les remboursements couvrent tout le paiement) ; - un remboursement refusé envoie seulement
refund.failed.
Pour refund.success / refund.failed, data est l'objet remboursement complété de transaction_reference, merchant_reference et 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
}
}Pour payment.refunded / payment.partially_refunded, data est le payload habituel de la transaction (voir ci-dessus) avec un objet data.refund supplémentaire : le remboursement qui a déclenché l'événement.
Signature
Chaque requête porte le header :
X-Soamtar-Signature: t=<unix>,v1=<hex_hmac_sha256>Pendant un changement de secret, deux signatures peuvent coexister :
X-Soamtar-Signature: t=<unix>,v1=<hmac_secret_courant>,v2=<hmac_secret_suivant>Vérification
- Extraire l'horodatage
tet la signaturev1(etv2si présente). - Vérifier que
test récent (dans la fenêtre de tolérance). - Recalculer
expected = HMAC-SHA256(t + "." + raw_body, secret). - Comparer en temps constant (
hash_equalsen PHP,crypto.timingSafeEqualen Node). - Si une
v2est présente, accepter si v1 OU v2 correspond.
$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');Tolérance
L'exemple utilise 300 secondes, un plafond raisonnable qui correspond à la tolérance appliquée par SOAMTAR.
Headers complémentaires
| Header | Sens |
|---|---|
X-Soamtar-Timestamp | Horodatage de signature (même valeur que t) |
X-Soamtar-Event | Type d'événement (payment.success, refund.success, webhook.test, ...) |
X-Soamtar-Delivery | Identifiant de la tentative de livraison — utile dans les logs |
User-Agent | Soamtar-Webhooks/1.0 (+https://app.so-amtar.com/docs/webhooks) |
Idempotence côté votre serveur
Le même événement peut arriver plusieurs fois (réessai après timeout, rejeu après incident). Dédupliquez sur le couple (event, data.id) et ignorez les doublons. Pour payment.refunded / payment.partially_refunded, ajoutez data.refund.id à la clé : un même paiement peut émettre payment.partially_refunded plusieurs fois, une par remboursement.
CREATE UNIQUE INDEX uq_inbound_event ON your_webhook_log(event, object_id, refund_id);Réessais et livraison
- 5 tentatives au maximum : la livraison initiale, puis des réessais après 60 s, 5 min, 30 min et 2 h.
- 2xx = succès, SOAMTAR s'arrête.
- 3xx = considéré comme un échec (votre endpoint doit répondre 2xx directement).
- 4xx, 5xx, timeout = échec, réessai programmé.
- Après un certain nombre d'échecs consécutifs, l'endpoint est suspendu et vous recevez un email.
Vous pouvez relancer manuellement depuis Dashboard → Webhook logs → Resend.
callback_url par transaction
POST /v1/payments et POST /v1/payouts acceptent un callback_url optionnel (HTTPS). SOAMTAR y envoie tous les événements de cette transaction, en plus de vos endpoints enregistrés :
- même payload (
{event, created, data}), mêmes headers ; - même
X-Soamtar-Signature, signée avec le secret de votre plus ancien endpoint webhook actif du même environnement (test ou live). Il vous faut donc au moins un endpoint enregistré pour recevoir les callbacks : sans endpoint, il n'y a aucun secret pour signer et le callback n'est pas envoyé ; - mêmes réessais (60 s, 5 min, 30 min, 2 h) ;
- pas d'envoi en double : si
callback_urlest égal à l'URL d'un de vos endpoints enregistrés, seule la livraison à l'endpoint a lieu.
Le callback reçoit les événements de la transaction quels que soient les abonnements de vos endpoints (événements de remboursement compris) : vérifiez la signature et dédupliquez exactement comme pour un endpoint.
Changement de secret sans coupure
- Dashboard → Webhooks → Rotate secret. SOAMTAR affiche le nouveau secret (copiez-le maintenant, il n'est jamais réaffiché).
- Pendant la fenêtre de rotation, chaque envoi peut être signé avec les deux secrets.
- Mettez à jour votre vérification pour accepter
v1OUv2. - Cliquez sur Promote : le nouveau secret devient le secret courant, l'ancien est invalidé.
Tester un webhook
Envoyez un événement webhook.test signé avec votre vrai secret depuis le dashboard (Dashboard → Webhooks → Test) ou avec POST /v1/webhooks/test et votre clé secrète. Il part vers tous les endpoints actifs de l'environnement de la clé, quels que soient leurs abonnements. Sans endpoint actif, l'API répond 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 } }
}Si vous ratez un webhook
- Endpoint de vérification :
POST /v1/payments/{id}/verifyfait interroger Orange Money par SOAMTAR et réémet l'événement si la transition a eu lieu. - Un rapprochement automatique interne rattrape les transactions bloquées : en général, vous n'avez rien à faire.