> ## Documentation Index
> Fetch the complete documentation index at: https://docs.alphapay.me/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Recevez en temps réel les changements de statut de vos transactions, retraits et transferts

Toutes les routes nécessitent votre clé API secrète (`Authorization: Bearer sk_...`) ou une session dashboard. Un point de réception (`webhook`) appartient à votre marchand ; un abonnement (`subscription`) rattache ce point de réception à un type d'événement précis — un même webhook peut avoir plusieurs abonnements.

```
Votre marchand
│
└── Webhook (url, environment, signing_secret)
    ├── Abonnement → transaction.success
    ├── Abonnement → settlement.success
    └── Abonnement → wallet_transfer.completed
```

<Note>
  Créer, modifier ou supprimer un webhook (ou un abonnement), ainsi que renvoyer manuellement une livraison, se fait **exclusivement depuis le [tableau de bord](https://app.alphapay.me)**. Seule la consultation (points de réception, abonnements, historique de livraison) reste accessible par clé API — voir [Accès dashboard vs. clé API](/account-setup#acc%C3%A8s-dashboard-vs-cl%C3%A9-api).
</Note>

## Catalogue d'events

| Event                       | Déclenchement                                                             |
| --------------------------- | ------------------------------------------------------------------------- |
| `transaction.created`       | Une transaction (payin ou payout) vient d'être initiée                    |
| `transaction.success`       | Une transaction a été confirmée avec succès                               |
| `transaction.failed`        | Une transaction a échoué                                                  |
| `transaction.cancelled`     | Une transaction a été annulée                                             |
| `settlement.requested`      | Un retrait a été demandé                                                  |
| `settlement.approved`       | Un retrait a été approuvé par un administrateur                           |
| `settlement.success`        | Un retrait a été exécuté avec succès                                      |
| `settlement.failed`         | Un retrait a échoué                                                       |
| `settlement.cancelled`      | Un retrait a été annulé (par le marchand ou refusé par un administrateur) |
| `wallet_transfer.requested` | Un transfert interwallet a été demandé                                    |
| `wallet_transfer.completed` | Un transfert interwallet a été complété                                   |
| `wallet_transfer.rejected`  | Un transfert interwallet a été rejeté                                     |
| `webhook.test`              | Event de test, déclenché manuellement pour vérifier votre intégration     |

<Note>
  Les events `settlement.*` sont en cours de fiabilisation côté plateforme — le contenu de `data` n'est pas garanti pour ces events précis pour le moment. Le catalogue reste correct, mais évitez de dépendre de la forme exacte de leur payload tant que ce point n'est pas stabilisé.
</Note>

## Format de l'enveloppe

Chaque event envoyé à votre URL partage la même structure :

```json theme={null}
{
  "event": "transaction.success",
  "data": { }
}
```

### `data` selon l'event

```json theme={null}
// transaction.success
{
  "event": "transaction.success",
  "data": {
    "id": "7c1a...",
    "reference": "TXN-2026-000456",
    "type": "PAIEMENT",
    "status": "SUCCESS",

    "amount": "25000.00",           // montant demandé
    "fee": "625.00",                // frais facturés au client
    "charged": "25625.00",          // ce que le payeur débourse réellement
    "net_amount": "25000.00",       // ce que vous recevez
    "fee_charge_mode": "ADD_ON",    // ADD_ON | DEDUCTED — décide qui paie les frais
    "currency": "XOF",

    "country": "BJ",                // code ISO 2 lettres
    "network": "mtn_bj",            // code réseau
    "msisdn": "22990000000"         // numéro débité (payin) ou crédité (payout)
  }
}

// wallet_transfer.completed
{
  "event": "wallet_transfer.completed",
  "data": {
    "id": "1a2b...",
    "reference": "f4e5d6c7...",
    "status": "COMPLETED",
    "from_country": "BJ",
    "from_amount": "100000.00",
    "from_currency": "XOF",
    "to_country": "CM",
    "to_amount": "984.00",
    "to_currency": "XAF"
  }
}
```

### Comprendre les montants

`amount` n'est **pas** ce que le payeur débourse : cela dépend de `fee_charge_mode`.

| Mode       | Le payeur débourse           | Vous recevez                    |
| ---------- | ---------------------------- | ------------------------------- |
| `ADD_ON`   | `charged` = `amount` + `fee` | `net_amount` = `amount`         |
| `DEDUCTED` | `charged` = `amount`         | `net_amount` = `amount` − `fee` |

Pour rapprocher votre comptabilité, utilisez `charged` (ce qui a quitté le compte du client) et `net_amount` (ce qui vous revient) — jamais `amount` seul.

<Note>
  `msisdn` est vide sur les transactions antérieures au 17 août 2026 : le numéro n'était alors pas conservé. `country`, `network` et les montants sont toujours présents.
</Note>

## Consulter vos webhooks

* [Lister vos points de réception](/api-reference/webhooks/list)
* [Récupérer un point de réception](/api-reference/webhooks/get)
* [Lister vos abonnements](/api-reference/webhooks/subscriptions-list)
* [Récupérer un abonnement](/api-reference/webhooks/subscriptions-get)

<Warning>
  Le secret de signature (`signing_secret`) n'est **jamais renvoyé en lecture** — ni par [Lister](/api-reference/webhooks/list), ni par [Récupérer](/api-reference/webhooks/get). Il n'apparaît qu'**une seule fois**, dans la réponse de création et dans celle d'une rotation de secret, y compris quand AlphaPay l'a généré pour vous. **Copiez-le à ce moment-là : il n'est plus jamais réaffichable.** Si vous l'avez perdu, définissez-en un nouveau depuis votre dashboard (Webhooks → Changer le secret) — les webhooks suivants seront signés avec celui-ci.
</Warning>

## Sécurité — vérifier la signature

Chaque requête envoyée à votre URL inclut trois headers, en plus du corps JSON de l'event :

```
POST <votre url>
Content-Type: application/json
X-Webhook-Signature: <hex sha256 HMAC, en minuscules>
X-Webhook-Timestamp: <horodatage Unix, en secondes>
X-Webhook-Event: <event_type, ex "transaction.success">

<le corps JSON exact sur lequel la signature a été calculée>
```

La signature couvre l'horodatage **et** le corps, séparés par un point :

```python theme={null}
signed = f"{timestamp}.{body}"
signature = hmac.new(signing_secret.encode(), signed.encode(), hashlib.sha256).hexdigest()
```

L'horodatage est régénéré à chaque tentative de livraison : une nouvelle tentative 2 h plus tard porte le sien.

### Deux contrôles, pas un seul

1. **L'âge** — rejetez si `X-Webhook-Timestamp` s'écarte de plus de **5 minutes** de votre horloge. Sans ce contrôle, un webhook légitime intercepté reste rejouable indéfiniment : sa signature ne périme jamais.
2. **La signature** — recalculez-la sur le **corps brut reçu**, jamais sur une re-sérialisation de `payload` que vous reconstruiriez : l'ordre des clés ou le formatage des nombres peuvent différer et casser la comparaison bit à bit.

L'horodatage étant inclus dans la signature, un attaquant ne peut pas le rajeunir pour contourner le contrôle d'âge — la signature ne correspondrait plus.

<CodeGroup>
  ```js Node.js theme={null}
  const crypto = require("crypto");
  const TOLERANCE_SECONDS = 300;

  function isValid(rawBody, signatureHeader, timestampHeader, secret) {
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - Number(timestampHeader)) > TOLERANCE_SECONDS) return false;

    const expected = crypto
      .createHmac("sha256", secret)
      .update(`${timestampHeader}.${rawBody}`)
      .digest("hex");

    const a = Buffer.from(signatureHeader);
    const b = Buffer.from(expected);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }
  ```

  ```php PHP theme={null}
  <?php
  const TOLERANCE_SECONDS = 300;

  // Corps BRUT, jamais json_decode puis re-encode.
  $body = file_get_contents('php://input');
  $signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
  $timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '0';

  if (abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) {
      http_response_code(403);
      exit('horodatage hors tolérance');
  }

  $expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret);
  if (!hash_equals($expected, $signature)) {
      http_response_code(403);
      exit('signature invalide');
  }

  $payload = json_decode($body, true);
  http_response_code(200);
  ```

  ```python Python theme={null}
  import hashlib, hmac, time

  TOLERANCE_SECONDS = 300

  def is_valid(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
      if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
          return False
      signed = f"{timestamp}.".encode() + raw_body
      expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, signature)
  ```
</CodeGroup>

<Note>
  Comparez toujours en **temps constant** (`hash_equals`, `timingSafeEqual`, `hmac.compare_digest`) et jamais avec `==` : une comparaison classique s'arrête au premier caractère différent, ce qui laisse fuiter la signature attendue par mesure du temps de réponse.
</Note>

## Historique de livraison

Chaque tentative de livraison (réussie, échouée, en cours de retry) est journalisée.

* [Lister l'historique de livraison](/api-reference/webhooks/logs-list)
* [Récupérer une livraison](/api-reference/webhooks/logs-get)

<Note>
  Pas de filtre disponible sur cette vue marchand — récupérez la liste et filtrez côté client. Un renvoi manuel se fait depuis le tableau de bord.
</Note>

## Fiabilité

* **5 tentatives** par event : immédiat, puis +30s, +5min, +30min, +2h.
* **Timeout de 15s** par tentative.
* Après épuisement des 5 tentatives, la livraison passe en statut `FAILED` **définitif** — seul un renvoi manuel depuis le tableau de bord peut la relancer, elle n'est plus retentée automatiquement.

<Note>
  Un abonnement ne peut être créé qu'une seule fois par couple (webhook, event\_type). Retenter d'abonner le même webhook au même `event_type` renvoie aujourd'hui une erreur serveur générique plutôt qu'un message dédié.
</Note>
