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

# Gérer les webhooks

> Recevez les événements de paiement et de retrait, vérifiez leur signature et traitez-les une seule fois.

Mercy Pay envoie un événement à votre serveur à chaque changement de statut d'un paiement ou d'un retrait. Fiez-vous à cet événement pour confirmer un encaissement, pas à la redirection du navigateur.

**1. Déclarez l'endpoint**

Dans l'onglet **Webhooks** de la [console Développeurs](https://docs.mercy-pay.com/developers/console.md), ajoutez une URL HTTPS publique et cochez les événements voulus. Mercy Pay affiche alors le **secret de signature** (`whsec_…`). Copiez-le dans vos secrets. Vous pourrez le réafficher plus tard depuis la console, après avoir confirmé votre mot de passe.

**2. Vérifiez la signature**

Chaque requête porte l'en-tête `MercyPay-Signature`, de la forme `t=1791022800,v1=5257a869…`. `t` est l'heure d'envoi, `v1` le HMAC-SHA256 en hexadécimal de `{t}.{corps brut}`, calculé avec votre secret. Refusez une signature de plus de 5 minutes : elle protège contre le rejeu d'une ancienne requête. Voir [Sécurité des webhooks](https://docs.mercy-pay.com/developers/webhooks/security.md).

```js JavaScript theme={"theme":{"light":"github-light","dark":"github-dark"}}
import crypto from 'node:crypto';

const TOLERANCE_SECONDS = 300;

export function isAuthentic(rawBody, signatureHeader, secret) {
  const parts = (signatureHeader ?? '').split(',').map((part) => part.split('='));
  const timestamp = Number(parts.find(([key]) => key === 't')?.[1]);
  const signatures = parts.filter(([key]) => key === 'v1').map(([, value]) => value);

  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
    return false;
  }

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

  return signatures.some(
    (signature) =>
      signature.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature)),
  );
}
```

```php PHP theme={"theme":{"light":"github-light","dark":"github-dark"}}
function isAuthentic(string $rawBody, ?string $signatureHeader, string $secret): bool
{
    $timestamp = null;
    $signatures = [];

    foreach (explode(',', $signatureHeader ?? '') as $part) {
        [$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');

        if ($key === 't') {
            $timestamp = (int) $value;
        } elseif ($key === 'v1') {
            $signatures[] = $value;
        }
    }

    if (! $timestamp || abs(time() - $timestamp) > 300) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);

    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }

    return false;
}
```

```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}}
import hashlib
import hmac
import time


def is_authentic(raw_body: bytes, signature_header: str | None, secret: str) -> bool:
    timestamp = None
    signatures = []

    for part in (signature_header or '').split(','):
        key, _, value = part.strip().partition('=')
        if key == 't' and value.isdigit():
            timestamp = int(value)
        elif key == 'v1':
            signatures.append(value)

    if timestamp is None or abs(time.time() - timestamp) > 300:
        return False

    signed = f'{timestamp}.'.encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

    return any(hmac.compare_digest(expected, signature) for signature in signatures)
```

Vérifiez le corps tel qu'il est reçu, avant tout `JSON.parse`.

**3. Répondez vite, traitez après**

Répondez `2xx` dès que la signature est validée, avant de lancer un traitement lourd. Mercy Pay n'attend que 10 secondes. Passé ce délai, ou sur une réponse qui n'est pas `2xx`, la livraison est considérée en échec et reprise plus tard.

| Tentative | Moment |
| - | - |
| 1 | immédiate |
| 2 | 1 minute après |
| 3 | 10 minutes après |
| 4 | 1 heure après |
| 5 | 3 heures après |
| 6 | 8 heures après |
| 7 | 1 jour après |
| 8 | 3 jours après |

Si votre serveur n'a accepté aucun événement pendant ces 3 jours, l'endpoint est désactivé et les développeurs de votre boutique reçoivent un e-mail. Corrigez le serveur, réactivez l'endpoint, puis cliquez sur **Relancer les échecs** dans la console. Les événements restent consultables avec [`GET /api/v1/events`](https://docs.mercy-pay.com/api/events/list).

**4. Dédupliquez par identifiant**

Une même livraison peut arriver deux fois, par exemple si le réseau coupe juste après votre `2xx`. Chaque événement porte un `id` (`evt_…`), répété dans l'en-tête `MercyPay-Event-Id` : enregistrez ceux que vous avez déjà traités et ignorez les doublons.

## Exemple d'événement

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "id": "evt_01J9Z4K2M8TQ5VX3WB7NRY6HDA",
  "object": "event",
  "type": "payment.succeeded",
  "created": "2026-10-03T10:20:00+00:00",
  "livemode": true,
  "data": {
    "id": "pay_5e0c8a7b3d1f4a2e9c648b7f0d2e1a93",
    "object": "payment",
    "livemode": true,
    "created": "2026-10-03T10:20:00+00:00",
    "checkout_session": "cs_01J9Z3V8QK4M2XW7R6TB5N0HYD",
    "amount": 15000,
    "fee_amount": 750,
    "net_amount": 14250,
    "currency": "XAF",
    "payment_method": "mobile_money",
    "mobile_money_country": "CMR",
    "mobile_money_provider": "MTN_MOMO_CMR",
    "status": "succeeded",
    "failure_code": null,
    "failure_reason": null,
    "metadata": null,
    "next_action": null
  }
}
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.