# Vérifier et tester (/fr/webhooks/receiving)



Vérifiez chaque webhook avant d’utiliser son payload, puis envoyez un test pour confirmer que votre récepteur fonctionne de bout en bout.

## Avant de commencer [#before-you-start]

* Une [destination enregistrée](/webhooks/configuration#save-a-destination) et son signing secret, `twhsec_...`. N’utilisez pas votre Server Secret Token pour vérifier les webhooks.
* Une route de réception capable de lire le corps brut de la requête avant qu’un analyseur JSON ne le modifie.
* Un stockage avec une contrainte d’unicité sur l’`id` de l’événement, afin que les doublons soient sans risque.

## Recevoir un webhook [#receive-a-webhook]

<div className="fd-steps [&_h3]:fd-step">
  ### Lire le corps brut [#read-the-raw-body]

  Tapaya envoie un `POST` JSON avec ces en-têtes. Conservez le corps sous forme d’octets d’origine ; un corps analysé puis sérialisé à nouveau ne correspond plus à la signature.

  ```http
  Content-Type: application/json
  X-Tapaya-Event-Id: 019a0000-0000-7000-8000-000000000001
  X-Tapaya-Signature: t=1793361600,v1=<lowercase hexadecimal HMAC-SHA256>
  ```

  * Avec Express, utilisez `express.raw({ type: "application/json" })` sur cette route, avant un analyseur JSON global.

  ### Vérifier la signature [#check-the-signature]

  Calculez un HMAC-SHA256 de l’horodatage, d’un point et du corps brut, puis comparez-le à `v1` en temps constant.

  ```js title="verify-tapaya-webhook.mjs"
  import { createHmac, timingSafeEqual } from "node:crypto";

  export function verifyTapayaWebhook(rawBody, signatureHeader, secret, now = Date.now()) {
    if (!Buffer.isBuffer(rawBody) || typeof signatureHeader !== "string") {
      throw new Error("Missing raw body or signature");
    }
    if (typeof secret !== "string" || !secret.startsWith("twhsec_")) {
      throw new Error("Missing signing secret");
    }

    const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(signatureHeader);
    if (!match) throw new Error("Malformed signature");

    const timestamp = Number(match[1]);
    if (!Number.isSafeInteger(timestamp) || Math.abs(now / 1000 - timestamp) > 300) {
      throw new Error("Expired signature");
    }

    const expected = createHmac("sha256", Buffer.from(secret, "utf8"))
      .update(`${match[1]}.`, "utf8")
      .update(rawBody)
      .digest();
    const received = Buffer.from(match[2], "hex");
    if (!timingSafeEqual(expected, received)) throw new Error("Invalid signature");

    return JSON.parse(rawBody.toString("utf8"));
  }
  ```

  * Utilisez le secret `twhsec_...` complet comme clé UTF-8. Ne le décodez pas et ne retirez pas son préfixe.
  * Rejetez les horodatages qui s’écartent de plus de cinq minutes de votre horloge, et maintenez l’horloge synchronisée.
  * Pendant le [renouvellement du signing secret](/webhooks/configuration#configuration-changes), essayez d’abord le nouveau secret, puis l’ancien, jusqu’à ce que la file d’attente soit vidée.

  ### Stocker et accuser réception [#store-and-acknowledge]

  N’acceptez l’événement qu’une fois qu’il est stocké de manière sûre, afin qu’un échec de votre côté déclenche une nouvelle tentative.

  ```js title="server.mjs"
  import express from "express";
  import { verifyTapayaWebhook } from "./verify-tapaya-webhook.mjs";
  import { saveEventOnce } from "./events.mjs"; // your store, unique on event id

  const app = express();

  app.post(
    "/webhooks/tapaya",
    express.raw({ type: "application/json" }),
    async (req, res) => {
      let event;
      try {
        event = verifyTapayaWebhook(
          req.body,
          req.get("X-Tapaya-Signature"),
          process.env.TAPAYA_WEBHOOK_SECRET,
        );
      } catch {
        return res.sendStatus(400);
      }
      if (event.organizationId !== process.env.TAPAYA_ORGANIZATION_ID) {
        return res.sendStatus(400);
      }

      await saveEventOnce(event); // skips an id it already stored
      res.sendStatus(204);
    },
  );
  ```

  * Renvoyez `2xx` pour un événement que vous avez déjà stocké. Les nouvelles tentatives conservent le même `id` et le même corps, avec un nouvel horodatage et une nouvelle signature.
  * Renvoyez une réponse autre que `2xx` lorsque vous ne pouvez pas stocker un nouvel événement, afin que la livraison soit retentée.
  * Traitez les événements stockés depuis votre propre file d’attente. L’ordre de livraison n’est pas garanti, effectuez donc le rapprochement des instantanés plus anciens avec l’état actuel.
</div>

## Envoyer un webhook de test [#send-a-test-webhook]

L’action de test de la Platform envoie une requête `webhook.test` signée à l’URL enregistrée. Elle fonctionne même si les webhooks sont désactivés ou si le type ne figure pas dans votre abonnement, et elle ne crée aucun paiement, remboursement ou commerçant.

1. Ouvrez **API Keys** pour votre organisation et enregistrez la destination.
2. Utilisez l’action de test de webhook.
3. Vérifiez le résultat, la durée et le JSON envoyé affichés dans la Platform.

```json
{
  "id": "019a0000-0000-7000-8000-000000000010",
  "type": "webhook.test",
  "apiVersion": "1",
  "organizationId": "019a0000-0000-7000-8000-000000000002",
  "merchantId": null,
  "createdAt": "2026-10-30T12:00:00.000000Z",
  "test": true,
  "data": {
    "message": "This is a test webhook from Tapaya. No payment was created."
  }
}
```

<Accordions type="multiple">
  <Accordion title="Ce que couvre un test" id="what-a-test-covers">
    * Une seule tentative, avec un délai maximal de 10 secondes et sans nouvelle tentative automatique.
    * Il vérifie la livraison et le traitement des signatures, pas les payloads de paiement ou d’onboarding. Testez ceux-ci avec de l’activité dans la sandbox.
    * Un échec de livraison apparaît comme un résultat de test, et non comme une erreur dans la Platform.
  </Accordion>

  <Accordion title="Limites des tests" id="test-limits">
    * Les livraisons en file et les tests manuels partagent 2 requêtes simultanées par organisation.
    * Les tests manuels autorisent également 10 démarrages sur une fenêtre glissante de 60 secondes par organisation, commune à tous les utilisateurs.
    * Au-delà de l’une ou l’autre limite, le test renvoie `429` avec `Retry-After` en secondes et n’envoie rien.
    * Les tests admis sont comptabilisés même en cas d’échec ou d’annulation. Recréer la destination ne réinitialise pas le décompte.
  </Accordion>
</Accordions>

## Récepteurs locaux et d’automatisation [#local-and-automation-receivers]

Faites pointer un webhook vers une machine de développement ou un outil sans code lorsque votre serveur de production n’est pas prêt.

<Accordions type="multiple">
  <Accordion title="Tester un récepteur local" id="test-a-local-receiver">
    * Enregistrez l’URL d’un tunnel HTTPS public comme destination ; les URL localhost et les adresses IP privées sont refusées.
    * Gardez la vérification des signatures activée, et assurez-vous que le tunnel transmet le corps brut et les en-têtes Tapaya sans modification.
    * Lorsque l’URL du tunnel change, enregistrez à nouveau la destination. Laissez l’ancien tunnel actif jusqu’à ce que les événements en attente pour celui-ci aient été livrés.
  </Accordion>

  <Accordion title="Connecter un outil d’automatisation" id="connect-an-automation-tool">
    * Le point d’entrée ne doit nécessiter aucun en-tête d’authentification personnalisé, doit conserver le corps original et répondre dans un délai de 10 secondes.
    * Il doit vérifier lui-même le HMAC du corps brut, ou se trouver derrière un adaptateur de vérification qui le fait.
    * Dès que l’outil répond `2xx`, la livraison est considérée comme réussie. Les échecs ultérieurs du workflow et les doublons sont à gérer par l’outil ; ils ne déclenchent pas de nouvelle livraison.
  </Accordion>
</Accordions>

## Gérer les erreurs [#handle-errors]

<Accordions type="multiple">
  <Accordion title="Résultats des tests" id="test-results">
    | Résultat          | Signification                                                                                                 |
    | ----------------- | ------------------------------------------------------------------------------------------------------------- |
    | `delivered: true` | Le récepteur a renvoyé HTTP `2xx`.                                                                            |
    | `http_error`      | Le récepteur a renvoyé un statut autre que `2xx`.                                                             |
    | `transport_error` | La destination n’a pas pu être atteinte, y compris en cas d’échec TLS, DNS ou de validation du réseau public. |
    | `timeout`         | Le récepteur n’a pas accusé réception dans un délai de 10 secondes.                                           |
  </Accordion>

  <Accordion title="Vérification des signatures" id="signature-checks">
    | Symptôme             | Vérification                                                                                                                            |
    | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
    | Signature différente | Utilisez le secret complet et les octets d’origine. Un JSON reformaté, ou le jeton API utilisé comme clé, fait échouer la vérification. |
    | Signature expirée    | Synchronisez l’horloge du récepteur et vérifiez par rapport à l’en-tête de la requête, et non à `createdAt`.                            |
  </Accordion>

  <Accordion title="Livraison" id="delivery">
    | Symptôme                                                   | Vérification                                                                                                                                                           |
    | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | URL rejetée ou connexion bloquée                           | Utilisez HTTPS public sur le port 443, sans redirections, identifiants ni fragment.                                                                                    |
    | Aucun événement webhook                                    | Vérifiez l’organisation, l’état d’activation et les types d’événements sélectionnés. Les changements passés ne sont pas rattrapés.                                     |
    | Événement en double ou plus ancien                         | Dédupliquez par `id`, et effectuez un rapprochement avec l’état actuel plutôt que de supposer un ordre. Consultez les [règles de livraison](/webhooks#delivery-rules). |
    | Le test fonctionne mais le traitement des paiements échoue | `webhook.test` a une structure `data` différente. Effectuez le routage selon `type` et testez avec de véritables événements de la sandbox.                             |
  </Accordion>
</Accordions>
