Aller au contenu principal

4. Sécuriser les notifications (callbacks)

Cette page s'adresse aux développeurs backend des marchands. Elle explique comment Papi sécurise les notifications (callbacks) qu'il envoie à votre notificationUrl, et comment vous devez sécuriser l'endpoint qui les reçoit.


Pourquoi sécuriser l'endpoint de callback

Votre notificationUrl est une URL publique. Papi l'appelle pour vous envoyer le statut final d'un paiement, mais toute personne qui connaît cette URL peut aussi y envoyer une requête POST. Par exemple, un attaquant peut envoyer une fausse notification avec "paymentStatus": "SUCCESS" pour une commande qui n'a jamais été payée.

Si votre endpoint met à jour la commande sans vérifier la provenance de la notification, vous risquez de livrer des biens ou des services qui n'ont jamais été payés. Votre endpoint doit vérifier chaque notification avant de modifier la moindre donnée.

Comment Papi protège les notifications

  • Chaque notification est signée. Papi signe chaque notification qu'il envoie à votre notificationUrl et place la signature dans l'en-tête X-Papi-Signature. Aucune notification n'est envoyée sans signature.
  • Un secret par application. La signature est calculée avec le secret de signature de votre application (boutique). Il n'est connu que de Papi, de votre serveur et des membres de votre organisation qui ont accès à la boutique dans le tableau de bord.
  • Protection contre le rejeu. Le message signé inclut un horodatage. Votre endpoint rejette les notifications trop anciennes : une notification interceptée ne peut donc pas être renvoyée plus tard.
  • Rétrocompatible. Le corps de la notification ne change pas et contient toujours le notificationToken. Les intégrations qui ne vérifient pas la signature continuent de fonctionner. Vous devez toutefois vérifier la signature avant de faire confiance à une notification.

Avant de faire confiance au corps, vérifiez la signature contenue dans l'en-tête X-Papi-Signature. Effectuez ensuite les contrôles supplémentaires sur merchantPaymentReference et notificationToken.

En-têtes envoyés avec une notification

En-têteValeur
Content-Typeapplication/json
Acceptapplication/json
User-AgentPAPI-Callback/1.0
Content-LengthTaille du corps, en octets.
X-Papi-Signaturet=<unix_seconds>,v1=<signature> — voir ci-dessous.

Exemple :

X-Papi-Signature: t=1757750400,v1=66c446f11f07c733a8ded2580681d7e0430cc490637479178506fd2a66595d53

Où trouver le secret de signature

Chaque application (boutique) possède un secret de signature. Papi le crée automatiquement à la création de l'application.

  1. Dans le tableau de bord, ouvrez la page de votre application.
  2. Ouvrez l'onglet Développeur.
  3. Le secret se trouve juste sous la clé API (Clé API), dans le champ Secret de signature des notifications (X-Papi-Signature).

Le secret a le format pwhsec_ suivi de 64 caractères hexadécimaux en minuscules (71 caractères au total).

Gardez le secret sur votre serveur

Traitez le secret de signature comme votre clé API : stockez-le uniquement côté serveur, jamais dans le code frontend, une application mobile ou une URL. Le secret ne peut pas être régénéré. En cas de fuite, contactez le support Papi.

Construction de la signature

L'en-tête X-Papi-Signature comporte deux parties, séparées par une virgule :

PartieDescription
tUnix timestamp, en secondes, du moment où Papi a signé la notification. Il fait partie du message signé : il ne peut donc pas être modifié sans invalider la signature.
v1La signature, en hexadécimal minuscule.

Papi calcule v1 comme suit :

signed_message = t + "." + raw_body
v1 = lowercase_hex( HMAC-SHA256( key = secret, message = signed_message ) )
  • key correspond aux octets UTF-8 de la chaîne complète du secret, préfixe pwhsec_ inclus.
  • raw_body correspond aux octets exacts du corps de la requête, tels qu'envoyés par Papi.

Vérifier la signature

Pour chaque notification reçue par votre endpoint :

  1. Lisez le corps brut de la requête, sous forme d'octets, avant tout parsing JSON.
  2. Lisez l'en-tête X-Papi-Signature et extrayez les valeurs de t et v1. Si l'en-tête est absent ou mal formé, rejetez la requête.
  3. Calculez la signature attendue : HMAC-SHA256 de t + "." + raw_body, avec votre secret comme clé, encodée en hexadécimal minuscule.
  4. Comparez la signature attendue avec v1 à l'aide d'une comparaison à temps constant.
  5. Rejetez la requête si l'écart entre l'heure actuelle et t dépasse 300 secondes. Cela vous protège contre le rejeu de notifications.
  6. Si l'un des contrôles échoue, répondez avec un statut non-2xx (par exemple 401) et ne traitez pas la notification.
  7. Si tous les contrôles réussissent, parsez le corps JSON et poursuivez avec les contrôles supplémentaires.

Papi considère toute réponse non-2xx comme une notification en échec.

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

const PAPI_WEBHOOK_SECRET = process.env.PAPI_WEBHOOK_SECRET; // pwhsec_...
const TOLERANCE_SECONDS = 300;

function verifyPapiSignature(rawBody, header, secret, toleranceSeconds = TOLERANCE_SECONDS) {
if (!Buffer.isBuffer(rawBody) || !header || !secret) return false;

const parts = {};
for (const item of header.split(',')) {
const [key, ...rest] = item.trim().split('=');
parts[key] = rest.join('=');
}
const { t, v1 } = parts;
if (!/^\d+$/.test(t || '') || !/^[0-9a-f]{64}$/.test(v1 || '')) return false;

const now = Math.floor(Date.now() / 1000);
if (toleranceSeconds > 0 && Math.abs(now - Number(t)) > toleranceSeconds) return false;

const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
return crypto.timingSafeEqual(expected, Buffer.from(v1, 'hex'));
}

const app = express();

// express.raw conserve le corps sous forme de Buffer : n'utilisez pas express.json() sur cette route
app.post('/payment-notify', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyPapiSignature(req.body, req.get('X-Papi-Signature'), PAPI_WEBHOOK_SECRET)) {
return res.status(401).end();
}

const notification = JSON.parse(req.body.toString('utf8'));
// Contrôles supplémentaires : merchantPaymentReference et notificationToken
// Mettre à jour votre commande à partir de notification.paymentStatus

res.status(200).end();
});

Tester votre implémentation

Utilisez ces valeurs pour vérifier votre code avant de recevoir une vraie notification :

EntréeValeur
Secretpwhsec_5f1c2b7e9a0d4c3b8e6f1a2d9c7b4e0f3a6d8c1b5e9f2a7d4c0b3e6f9a1d8c2b
t1757750400
Corps brut{"paymentReference":"PAPI-TEST-0001","paymentStatus":"SUCCESS","amount":150000}

En-tête attendu :

X-Papi-Signature: t=1757750400,v1=66c446f11f07c733a8ded2580681d7e0430cc490637479178506fd2a66595d53

Vous pouvez calculer la même signature avec openssl. Le condensat affiché doit être égal à la valeur de v1 :

printf '%s' '1757750400.{"paymentReference":"PAPI-TEST-0001","paymentStatus":"SUCCESS","amount":150000}' \
| openssl dgst -sha256 -hmac 'pwhsec_5f1c2b7e9a0d4c3b8e6f1a2d9c7b4e0f3a6d8c1b5e9f2a7d4c0b3e6f9a1d8c2b'
remarque

Le t de ce vecteur de test est dans le passé. Lorsque vous testez votre code avec ce vecteur, désactivez le contrôle des 300 secondes (dans les exemples ci-dessus, passez une tolérance de 0). Laissez ce contrôle activé en production.

Pièges courants

SymptômeCause
La signature ne correspond jamais, alors que le secret est correctLe corps JSON a été parsé puis resérialisé avant le calcul du HMAC. L'ordre des clés, les espaces ou l'échappement des caractères changent, donc les octets diffèrent. Utilisez toujours le corps brut.
La signature ne correspond jamaisLa clé n'inclut pas le préfixe pwhsec_. La clé est la chaîne complète du secret.
Le corps brut est vide ou déjà un objetUn middleware du framework (par exemple express.json() ou un filtre de journalisation des requêtes) a lu et parsé le corps avant votre handler. Lisez le corps brut sur la route de notification.
Des notifications valides sont rejetées comme trop anciennesL'horloge de votre serveur n'est pas synchronisée. Synchronisez-la avec NTP.
La vérification fonctionne mais n'est pas sûreLes signatures sont comparées avec == ou ===. Utilisez une comparaison à temps constant (crypto.timingSafeEqual, hash_equals, hmac.compare_digest, MessageDigest.isEqual).

Contrôles supplémentaires

Une fois la signature validée, vous pouvez aussi vérifier que :

  • merchantPaymentReference correspond à la référence que vous avez envoyée.
  • notificationToken correspond à celui que vous avez reçu dans la réponse de création du lien de paiement.

Si la signature et ces deux contrôles sont valides, la notification est authentique et vous pouvez mettre à jour votre base de données en toute sécurité.

Sécuriser l'endpoint lui-même

Une signature valide prouve qu'une notification provient de Papi. Appliquez aussi ces bonnes pratiques à l'endpoint qui reçoit les notifications.

Utilisez HTTPS

En production, servez votre endpoint de notification en HTTPS.

Gardez les secrets hors de l'URL

Ne mettez jamais votre clé API ni votre secret de signature dans la notificationUrl, par exemple dans une query string. Les URL sont écrites dans les journaux des serveurs, des proxys et des tunnels.

Répondez rapidement

  • Répondez avec un statut 2xx dès que la notification est vérifiée et enregistrée. Effectuez les traitements lourds (emails, mises à jour de stock, appels à d'autres systèmes) après avoir répondu.
  • Papi attend au maximum 10 secondes pour se connecter à votre endpoint et au maximum 30 secondes pour obtenir la réponse.
  • Papi considère toute réponse non-2xx, ainsi que tout dépassement de délai, comme une notification en échec.

Gérez les notifications en double

Votre endpoint peut recevoir plusieurs fois la notification d'un même paiement, par exemple lorsque la notification est renvoyée. Traitez les notifications de manière idempotente :

  • Identifiez le paiement avec paymentReference ou merchantPaymentReference. Si le paiement est déjà dans son état final dans votre base de données, répondez avec un statut 2xx et ne le traitez pas une seconde fois.
  • N'utilisez pas la signature pour détecter les doublons. Une notification renvoyée a une nouvelle valeur t et une nouvelle signature.

Notifications en échec

Papi envoie une notification une seule fois, automatiquement. Si elle a échoué, vous pouvez :

  • La renvoyer depuis le tableau de bord Papi : ouvrez le détail du paiement, ouvrez l'onglet Développeur, puis cliquez sur Renvoyer le callback.
  • Relire l'issue du paiement avec GET /engine/api/payment-links/{merchantPaymentReference}. Voir Relire un lien de paiement.

Autorisez l'agent utilisateur de Papi

Certains pare-feu et pare-feu applicatifs web (WAF) bloquent les requêtes provenant d'agents utilisateurs inconnus. Autorisez les requêtes portant l'en-tête User-Agent: PAPI-Callback/1.0 sur votre endpoint de notification.

Développement local

Papi ne peut pas joindre localhost depuis ses serveurs. Pour recevoir des notifications sur votre machine locale, utilisez un tunnel : voir l'étape Créez l'endpoint de notification (URL de callback) dans le guide d'intégration.