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

ErreurCause typique
invalid signatureVous re-stringifiez le JSON avant de signer : utilisez le body brut
invalid signatureMauvais secret (test vs live)
expired_timestampHorloge serveur désynchronisée : vérifiez NTP
missing_signatureReverse proxy strippe le header : configurez-le pour passer X-IziPay-*
malformed_signatureQuelqu’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;
}
  1. Créez un nouvel endpoint (vous pouvez réutiliser la même URL) et notez son secret NEW_SECRET.
  2. Mettez votre serveur à jour pour accepter les deux secrets (ci-dessus).
  3. Supprimez l’ancien endpoint, puis retirez OLD_SECRET.