Vérifier la signature des webhooks
N'importe qui peut envoyer une requête à votre adresse de réception : seule la signature prouve qu'un webhook vient d'Integral Pay et n'a pas été modifié. Vérifiez-la avant toute autre action, et refusez tout webhook dont la signature est invalide ou l'horodatage trop ancien.
En-têtes envoyés
| En-tête | Contenu |
|---|---|
X-Signature-SHA256 | Signature HMAC-SHA256, en hexadécimal (64 caractères) |
X-Webhook-Timestamp | Heure d'envoi, en secondes Unix (ex. 1762425330) |
X-Webhook-ID | Identifiant de l'événement, identique pour toutes les tentatives |
X-Webhook-Delivery | Identifiant de cette tentative d'envoi |
X-Webhook-Event | Type d'événement : payment.updated |
User-Agent | IntegralPay-Webhooks/1.0 |
Calcul de la signature
Le texte signé est l'horodatage, un point, puis le corps brut de la requête, octet pour octet. La clé est votre secret de webhook.
signature = hex( HMAC-SHA256( secret, X-Webhook-Timestamp + "." + corps_brut ) )
- Lisez le corps brut, avant tout décodage JSON. Un JSON décodé puis réencodé change d'octets (espaces, ordre des clés, caractères accentués) et la signature ne correspond plus.
- Contrôlez l'horodatage : refusez le webhook s'il diffère de plus de 5 minutes de votre heure. Un webhook intercepté ne pourra pas être rejoué plus tard. Gardez l'horloge de votre serveur synchronisée (NTP).
- Comparez à temps constant la signature calculée et
X-Signature-SHA256:hmac.compare_digest,hash_equals,crypto.timingSafeEqual. Jamais==. - Seulement ensuite, décodez le JSON et traitez l'événement.
Exemples
- Python · Django
- Python · Flask
- Node.js · Express
- PHP
webhooks.py
import hashlib
import hmac
import json
import os
import time
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
WEBHOOK_SECRET = os.environ["INTEGRALPAY_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300
def signature_valide(timestamp: str, signature: str, body: bytes) -> bool:
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
attendue = hmac.new(WEBHOOK_SECRET, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(attendue, signature)
@csrf_exempt
@require_POST
def integralpay_webhook(request):
if not signature_valide(
request.headers.get("X-Webhook-Timestamp", ""),
request.headers.get("X-Signature-SHA256", ""),
request.body, # corps brut, en octets
):
return HttpResponseBadRequest("Signature invalide")
event = json.loads(request.body)
# ... traitement : voir « Idempotence »
return HttpResponse(status=200)
app.py
import hashlib, hmac, os, time
from flask import Flask, abort, request
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["INTEGRALPAY_WEBHOOK_SECRET"].encode()
@app.post("/integralpay/webhook/")
def integralpay_webhook():
timestamp = request.headers.get("X-Webhook-Timestamp", "")
signature = request.headers.get("X-Signature-SHA256", "")
body = request.get_data() # corps brut, en octets
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
abort(400)
attendue = hmac.new(WEBHOOK_SECRET, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(attendue, signature):
abort(400)
event = request.get_json()
# ... traitement
return "", 200
webhook.js
const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.INTEGRALPAY_WEBHOOK_SECRET;
// express.raw : le corps reste brut (Buffer) pour CETTE route, avant tout express.json()
app.post('/integralpay/webhook/', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('X-Webhook-Timestamp') || '';
const signature = req.get('X-Signature-SHA256') || '';
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).send('Horodatage invalide');
}
const attendue = crypto.createHmac('sha256', SECRET)
.update(`${timestamp}.`)
.update(req.body)
.digest('hex');
const a = Buffer.from(attendue);
const b = Buffer.from(signature);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(400).send('Signature invalide');
}
const event = JSON.parse(req.body.toString('utf8'));
// ... traitement
res.sendStatus(200);
});
webhook.php
<?php
$secret = getenv('INTEGRALPAY_WEBHOOK_SECRET');
$body = file_get_contents('php://input'); // corps brut
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_SIGNATURE_SHA256'] ?? '';
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
http_response_code(400);
exit('Horodatage invalide');
}
$attendue = hash_hmac('sha256', $timestamp . '.' . $body, $secret);
if (!hash_equals($attendue, $signature)) {
http_response_code(400);
exit('Signature invalide');
}
$event = json_decode($body, true);
// ... traitement
http_response_code(200);
Si la vérification échoue
Contrôlez dans l'ordre :
- Le secret : celui de cette adresse de réception (chaque adresse a le sien ; le secret de test diffère de celui de production), sans espace ni retour à la ligne en trop, et pas l'ancien si vous l'avez renouvelé.
- Le corps brut : aucun middleware (
express.json(),bodyParser, un framework qui décode le JSON) ne doit le transformer avant vous. - L'horloge de votre serveur, si seuls les contrôles d'horodatage échouent.