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
notificationUrlet place la signature dans l'en-têteX-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ête | Valeur |
|---|---|
Content-Type | application/json |
Accept | application/json |
User-Agent | PAPI-Callback/1.0 |
Content-Length | Taille du corps, en octets. |
X-Papi-Signature | t=<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.
- Dans le tableau de bord, ouvrez la page de votre application.
- Ouvrez l'onglet Développeur.
- 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).
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 :
| Partie | Description |
|---|---|
t | Unix 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. |
v1 | La 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 ) )
keycorrespond aux octets UTF-8 de la chaîne complète du secret, préfixepwhsec_inclus.raw_bodycorrespond 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 :
- Lisez le corps brut de la requête, sous forme d'octets, avant tout parsing JSON.
- Lisez l'en-tête
X-Papi-Signatureet extrayez les valeurs detetv1. Si l'en-tête est absent ou mal formé, rejetez la requête. - Calculez la signature attendue : HMAC-SHA256 de
t + "." + raw_body, avec votre secret comme clé, encodée en hexadécimal minuscule. - Comparez la signature attendue avec
v1à l'aide d'une comparaison à temps constant. - Rejetez la requête si l'écart entre l'heure actuelle et
tdépasse 300 secondes. Cela vous protège contre le rejeu de notifications. - Si l'un des contrôles échoue, répondez avec un statut non-2xx (par exemple
401) et ne traitez pas la notification. - 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.
- Node.js (Express)
- PHP
- Python (Flask)
- Java (Spring)
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();
});
<?php
const TOLERANCE_SECONDS = 300;
function verifyPapiSignature(string $rawBody, ?string $header, string $secret, int $toleranceSeconds = TOLERANCE_SECONDS): bool
{
if ($header === null || $header === '' || $secret === '') {
return false;
}
$parts = [];
foreach (explode(',', $header) as $item) {
$pair = explode('=', trim($item), 2);
if (count($pair) === 2) {
$parts[$pair[0]] = $pair[1];
}
}
$t = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';
if (!ctype_digit($t) || !preg_match('/\A[0-9a-f]{64}\z/', $v1)) {
return false;
}
if ($toleranceSeconds > 0 && abs(time() - (int) $t) > $toleranceSeconds) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $v1);
}
// payment-notify.php (ou le chemin correspondant à votre notificationUrl)
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_PAPI_SIGNATURE'] ?? null;
$secret = getenv('PAPI_WEBHOOK_SECRET') ?: ''; // pwhsec_...
if ($rawBody === false || !verifyPapiSignature($rawBody, $header, $secret)) {
http_response_code(401);
exit();
}
$data = json_decode($rawBody, true);
// Contrôles supplémentaires : merchantPaymentReference et notificationToken
// Mettre à jour votre commande à partir de $data['paymentStatus']
http_response_code(200);
import hashlib
import hmac
import json
import os
import re
import time
from flask import Flask, abort, request
app = Flask(__name__)
PAPI_WEBHOOK_SECRET = os.environ["PAPI_WEBHOOK_SECRET"] # pwhsec_...
TOLERANCE_SECONDS = 300
def verify_papi_signature(raw_body, header, secret, tolerance_seconds=TOLERANCE_SECONDS):
if not header or not secret:
return False
parts = dict(item.strip().split("=", 1) for item in header.split(",") if "=" in item)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not re.fullmatch(r"[0-9]+", t) or not re.fullmatch(r"[0-9a-f]{64}", v1):
return False
if tolerance_seconds > 0 and abs(int(time.time()) - int(t)) > tolerance_seconds:
return False
expected = hmac.new(secret.encode("utf-8"), t.encode("ascii") + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/payment-notify")
def payment_notify():
raw_body = request.get_data() # octets bruts, avant tout parsing JSON
if not verify_papi_signature(raw_body, request.headers.get("X-Papi-Signature"), PAPI_WEBHOOK_SECRET):
abort(401)
notification = json.loads(raw_body)
# Contrôles supplémentaires : merchantPaymentReference et notificationToken
# Mettre à jour votre commande à partir de notification["paymentStatus"]
return "", 200
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class NotificationController {
private static final long TOLERANCE_SECONDS = 300;
@Value("${papi.webhook-secret}")
private String webhookSecret; // pwhsec_...
@PostMapping("/payment-notify")
public ResponseEntity<Void> handleNotification(@RequestBody byte[] body, @RequestHeader(value = "X-Papi-Signature", required = false) String signature) throws Exception {
if (!verifyPapiSignature(body, signature, webhookSecret, TOLERANCE_SECONDS)) {
return ResponseEntity.status(401).build();
}
// Parser le JSON seulement maintenant, par exemple : objectMapper.readValue(body, Map.class)
// Contrôles supplémentaires : merchantPaymentReference et notificationToken
return ResponseEntity.ok().build();
}
static boolean verifyPapiSignature(byte[] rawBody, String header, String secret, long toleranceSeconds) throws Exception {
if (header == null || secret == null || secret.isEmpty()) {
return false;
}
Map<String, String> parts = new HashMap<>();
for (String item : header.split(",")) {
String[] pair = item.trim().split("=", 2);
if (pair.length == 2) {
parts.put(pair[0], pair[1]);
}
}
String t = parts.getOrDefault("t", "");
String v1 = parts.getOrDefault("v1", "");
if (!t.matches("[0-9]{1,18}") || !v1.matches("[0-9a-f]{64}")) {
return false;
}
long now = System.currentTimeMillis() / 1000;
if (toleranceSeconds > 0 && Math.abs(now - Long.parseLong(t)) > toleranceSeconds) {
return false;
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((t + ".").getBytes(StandardCharsets.UTF_8));
byte[] expected = mac.doFinal(rawBody);
return MessageDigest.isEqual(expected, HexFormat.of().parseHex(v1));
}
}
HexFormat nécessite Java 17 ou une version ultérieure.
Tester votre implémentation
Utilisez ces valeurs pour vérifier votre code avant de recevoir une vraie notification :
| Entrée | Valeur |
|---|---|
| Secret | pwhsec_5f1c2b7e9a0d4c3b8e6f1a2d9c7b4e0f3a6d8c1b5e9f2a7d4c0b3e6f9a1d8c2b |
t | 1757750400 |
| 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'
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ôme | Cause |
|---|---|
| La signature ne correspond jamais, alors que le secret est correct | Le 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 jamais | La 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 objet | Un 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 anciennes | L'horloge de votre serveur n'est pas synchronisée. Synchronisez-la avec NTP. |
| La vérification fonctionne mais n'est pas sûre | Les 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 :
merchantPaymentReferencecorrespond à la référence que vous avez envoyée.notificationTokencorrespond à 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
2xxdè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
paymentReferenceoumerchantPaymentReference. Si le paiement est déjà dans son état final dans votre base de données, répondez avec un statut2xxet 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
tet 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.