Vérifier la signature d’un webhook avant tout traitement est non-négociable. Sans vérification, n’importe qui peut envoyer un faux payment_intent.completed à votre endpoint et marquer une commande comme payée sans qu’aucun fonds n’ait été reçu.
Le contrat
POST /your-endpoint
X-IziPay-Signature: sha256=<hex>
X-IziPay-Timestamp: 1731843299
Content-Type: application/json
{"event":"payment_intent.completed","timestamp":1731843299,"data":{...}}
Signature : HMAC-SHA256(secret, raw_body) en hex
Timestamp : aussi à l’intérieur du body signé
Pourquoi le timestamp est important
Sans vérification de timestamp, un attaquant qui capture un webhook valide peut le rejouer indéfiniment. Le timestamp inclus dans le body signé :
- Bloque les replays > 5 minutes
- Permet à votre serveur de détecter si le timestamp est trop loin dans le futur (clock skew exploitable)
Avec le SDK Node (recommandé)
import { IziPayClient, IziPayWebhookError } from 'izichangepay-sdk';
try {
const event = IziPayClient.validateWebhook(
rawBody, // Buffer brut, PAS du JSON parsé
req.headers['x-izipay-signature'],
process.env.IZIPAY_WEBHOOK_SECRET,
);
// event est typé : { type, timestamp, data }
} catch (err) {
if (err instanceof IziPayWebhookError) {
console.error('Webhook rejected:', err.reason);
// reason ∈ 'missing_signature' | 'malformed_signature' | 'invalid_signature'
// | 'missing_timestamp' | 'expired_timestamp' | 'invalid_body'
}
return res.status(400).json({ error: 'invalid' });
}
Le SDK :
- Compare la signature en temps constant (
crypto.timingSafeEqual)
- Rejette les headers multi-value (header
Signature dupliqué → ambigu → reject)
- Tolérance par défaut 5 min (configurable via
toleranceSeconds)
- Rejette les timestamps > 60s dans le futur
Sans SDK (autre langage)
import hmac
import hashlib
import time
import json
def validate_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance_seconds=300):
# 1. Parser le header
if not signature_header.startswith('sha256='):
raise ValueError('malformed signature')
provided = signature_header[len('sha256='):]
# 2. Calculer le HMAC attendu
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
# 3. Compare constant-time
if not hmac.compare_digest(provided, expected):
raise ValueError('invalid signature')
# 4. Anti-replay
payload = json.loads(raw_body)
ts = payload.get('timestamp')
if not isinstance(ts, (int, float)):
raise ValueError('missing timestamp')
age = time.time() - ts
if age > tolerance_seconds:
raise ValueError(f'replay attempt, age={age}s')
if age < -60:
raise ValueError('timestamp in future')
return payload
function validateWebhook($rawBody, $sigHeader, $secret, $tolerance = 300) {
if (substr($sigHeader, 0, 7) !== 'sha256=') {
throw new Exception('malformed signature');
}
$provided = substr($sigHeader, 7);
$expected = hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, $provided)) {
throw new Exception('invalid signature');
}
$payload = json_decode($rawBody, true);
$age = time() - ($payload['timestamp'] ?? 0);
if ($age > $tolerance) throw new Exception('replay');
if ($age < -60) throw new Exception('future');
return $payload;
}
Erreurs courantes
| Erreur | Cause typique |
|---|
invalid signature | Vous re-stringifiez le JSON avant de signer : utilisez le body brut |
invalid signature | Mauvais secret (test vs live) |
expired_timestamp | Horloge serveur désynchronisée : vérifiez NTP |
missing_signature | Reverse proxy strippe le header : configurez-le pour passer X-IziPay-* |
malformed_signature | Quelqu’un a parsé le header (split sur =) au lieu de le passer tel quel |
Ne JAMAIS désactiver la vérification “temporairement pour debug”. Si vous ne pouvez pas faire passer un webhook, comparez en local avec un test depuis le dashboard (Webhooks → Envoyer test). Le payload est identique à un événement réel.
Changer de secret
Le secret d’un endpoint est généré une seule fois, à sa création, et n’est jamais régénéré : il n’existe pas de rotation in-place ni de bouton « Rotate secret ». Pour utiliser un nouveau secret, créez un nouvel endpoint (vous recevez un nouveau whsec_…), puis supprimez l’ancien.
Pour basculer sans perdre d’événement, faites se chevaucher les deux endpoints le temps de la transition. Tant que les deux sont actifs, votre serveur reçoit chaque événement deux fois (une fois par secret) : dédupliquez via l’id de l’événement.
// Pendant la transition, acceptez l'un OU l'autre secret
function validateAny(rawBody, sig) {
for (const secret of [NEW_SECRET, OLD_SECRET]) {
try { return IziPayClient.validateWebhook(rawBody, sig, secret); } catch {}
}
return null;
}
- Créez un nouvel endpoint (vous pouvez réutiliser la même URL) et notez son secret
NEW_SECRET.
- Mettez votre serveur à jour pour accepter les deux secrets (ci-dessus).
- Supprimez l’ancien endpoint, puis retirez
OLD_SECRET.