> For the complete documentation index, see [llms.txt](https://docs.intram.org/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.intram.org/merchant-api-v1/webhooks.md).

# Recevoir les webhooks signés

Quand une opération asynchrone du Merchant API change d'état (payout terminé, paiement reçu, remboursement settled…), Intram fait un `POST` HTTP signé vers l'URL que vous avez configurée. C'est le moyen recommandé de suivre l'état des opérations — bien plus efficace que du polling.

## Configurer un endpoint webhook

Via l'API (recommandé pour automatiser) :

```bash
curl -X POST https://api.intram.org/v1/webhooks \
  -H "X-Api-Key: $PUB" -H "X-Timestamp: $TS" -H "X-Signature: $SIG" \
  -H "Idempotency-Key: webhook-setup-1" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://my-app.example.com/intram/webhook",
    "event": "*"
  }'
```

Le champ `event` accepte :

| Pattern            | Comportement                                                         |
| ------------------ | -------------------------------------------------------------------- |
| `payout.completed` | Match exact d'un seul event                                          |
| `payout.*`         | Tous les events `payout.completed`, `payout.failed`, `payout.queued` |
| `*` ou `all`       | Tous les events                                                      |

{% hint style="warning" %}
La réponse de création contient `secret` — c'est ta **clé HMAC pour vérifier les webhooks entrants**. Elle n'est **affichée qu'une seule fois**, stocke-la dans un coffre-fort (Vault, AWS Secrets Manager, etc.). Si tu la perds, recrée le webhook.
{% endhint %}

## Headers d'un webhook entrant

Chaque livraison porte :

```http
Content-Type:        application/json
X-Intram-Signature:  sha256=<hex>
X-Intram-Timestamp:  2026-05-20T10:31:14.000Z
X-Intram-Event:      payout.completed
X-Intram-Delivery:   whd_550e8400e29b41d4a716446655440000
X-Intram-Attempt:    1
User-Agent:          Intram-Webhooks/1.0
```

* `X-Intram-Signature` — HMAC-SHA256 de `timestamp.raw_body` avec le secret du webhook
* `X-Intram-Timestamp` — ISO 8601. Rejette si > 5 minutes
* `X-Intram-Delivery` — identifiant unique de la tentative. Utilise-le pour dédupliquer côté toi en cas de double livraison
* `X-Intram-Attempt` — numéro de tentative (1 à 5)

## Body d'un webhook

```json
{
  "event": "payout.completed",
  "operation_id": "op_2f4a…",
  "occurred_at": "2026-05-20T10:31:14.000Z",
  "data": {
    "reference": "PO-2026-0001",
    "amount": 25000,
    "currency": "XOF",
    "status": "completed",
    "destination": { "type": "mobile_money", "msisdn": "22961234567" }
  }
}
```

## Vérifier la signature

{% code title="webhook-verifier.js (Node.js / Express)" %}

```javascript
const crypto = require('crypto');
const express = require('express');

const WEBHOOK_SECRET = process.env.INTRAM_WEBHOOK_SECRET;

const app = express();

// IMPORTANT : récupérer le RAW body, pas le JSON parsé
app.post('/intram/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-intram-signature'];
    const timestamp = req.headers['x-intram-timestamp'];
    const delivery  = req.headers['x-intram-delivery'];

    // 1. Anti-replay : refuse si > 5 minutes
    if (Math.abs(Date.now() - Date.parse(timestamp)) > 5 * 60_000) {
      return res.status(401).send('Timestamp out of window');
    }

    // 2. Recalcule la signature attendue
    const expected = 'sha256=' + crypto
      .createHmac('sha256', WEBHOOK_SECRET)
      .update(`${timestamp}.${req.body}`)
      .digest('hex');

    // 3. Comparaison timing-safe
    const a = Buffer.from(signature || '');
    const b = Buffer.from(expected);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).send('Invalid signature');
    }

    // 4. Dédup par X-Intram-Delivery (au cas où un retry passe en double)
    if (alreadySeen(delivery)) return res.status(200).send('ok');
    markSeen(delivery);

    // 5. Parse et traite
    const event = JSON.parse(req.body.toString('utf8'));
    handleEvent(event);

    // 6. Toujours répondre 2xx pour acquitter
    res.status(200).send('ok');
  }
);
```

{% endcode %}

{% hint style="danger" %}
**Si tu utilises `express.json()` globalement**, ton middleware aura déjà mangé le raw body — la signature ne matchera jamais. Il faut soit appliquer `express.raw()` spécifiquement sur la route webhook, soit conserver le raw body via une option de bodyParser.
{% endhint %}

## Politique de retry

|   Tentative  | Délai après la précédente              |
| :----------: | -------------------------------------- |
|       1      | immédiat                               |
|       2      | 1 min                                  |
|       3      | 5 min                                  |
|       4      | 30 min                                 |
|       5      | 2 h                                    |
|    (puis)    | 12 h                                   |
| Au-delà de 5 | livraison marquée `exhausted`, abandon |

* Une réponse **2xx** acquitte la livraison — pas de retry.
* Toute autre réponse (4xx, 5xx, timeout > 15 s, connexion refusée) déclenche un retry suivant le calendrier ci-dessus.
* Les retries portent un `X-Intram-Attempt` incrémenté mais le **même** `X-Intram-Delivery` — c'est ce qui te permet de dédupliquer.

## Catalogue des events

| Event                     | Émis quand                                                                |
| ------------------------- | ------------------------------------------------------------------------- |
| `payout.completed`        | Le payout mobile money a été confirmé par le provider                     |
| `payout.queued`           | Le payout bancaire a été accepté pour traitement back-office              |
| `payout.failed`           | Le payout a été rejeté (solde, provider, validation)                      |
| `payment_request.created` | La demande de paiement est prête, `gateway_url` disponible                |
| `payment_request.paid`    | Le client a payé avec succès                                              |
| `payment_request.pending` | Le client a initié mais le provider n'a pas confirmé                      |
| `payment_request.failed`  | Le flow paiement a échoué côté client ou provider                         |
| `refund.completed`        | Le refund a été settled (mobile money succeeded ou Stripe refund created) |
| `refund.pending`          | Le refund est accepté par le provider mais pas encore settled             |
| `refund.failed`           | Le refund a été rejeté                                                    |
| `test.ping`               | Envoi de test via `POST /webhooks/:subscription_id/test`                  |

## Bonnes pratiques

1. **Réponds rapidement** (sous 5 secondes). Si ton handler doit faire du travail lourd, queue-le puis renvoie 200 tout de suite.
2. **Idempotence** côté toi : un event peut arriver plusieurs fois (rare mais possible). Dédup sur `X-Intram-Delivery`.
3. **Endpoint HTTPS uniquement** — la création d'un webhook refuse les URLs `http://`.
4. **Logge les `X-Intram-Attempt > 1`** — un retry signifie que ton premier renvoi a échoué, c'est un signal de monitoring.
5. **Endpoint de test** : utilise `POST /webhooks/:id/test` pour vérifier que ton handler fonctionne sans déclencher une vraie opération.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.intram.org/merchant-api-v1/webhooks.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
