Webhooks PayPal : vérifier l’authenticité des notifications serveur

Guide pratique pour sécuriser les notifications PayPal sur PrestaShop : validation de signature, appel verify-webhook-signature, idempotence et contrôles métier.

Deux écrans affichant des données liées à PayPal et à la sécurité informatique.

Table des matières :

  1. Modèle de menace : ce que vous risquez si vous ne vérifiez pas les webhooks PayPal
  2. Ce que PayPal fournit pour prouver l’authenticité : signatures, en-têtes et endpoint de vérification
  3. Implémentation PHP côté PrestaShop (8.1/8.2/9.x) : corps brut, validation de signature, idempotence

Les webhooks PayPal sont des requêtes HTTP entrantes déclenchées côté PayPal (ex. PAYMENT.CAPTURE.COMPLETED, CHECKOUT.ORDER.APPROVED) pour informer votre serveur d’un changement d’état. Le point critique : sans vérifier l’authenticité des notifications serveur, vous n’avez qu’un POST anonyme venant d’Internet. Sur un checkout e‑commerce, ça suffit à transformer une boutique en distributeur gratuit.

Dans un contexte très courant (boutique PrestaShop hébergée chez un prestataire FR/EU, back‑office accessible, modules tiers), le webhook est souvent branché sur des actions “à fort impact” : passage de commande en “paiement accepté”, déclenchement d’une facture, décrémentation de stock, création d’expédition, synchronisation ERP, etc. Plus l’automatisation est bonne, plus l’absence de vérification est dangereuse.


Modèle de menace : ce que vous risquez si vous ne vérifiez pas les webhooks PayPal

Le scénario d’attaque le plus banal est aussi le plus destructeur : un acteur externe appelle votre endpoint webhook avec un JSON ressemblant à un événement “paiement complété”, et votre code met la commande PrestaShop en “Paiement accepté”. Si vous ne validez pas la signature PayPal (et/ou si vous vous contentez d’un “secret” en query string), l’attaquant n’a aucune barrière cryptographique. Ce n’est pas théorique : en audit, on tombe régulièrement sur des handlers qui se contentent de if ($event['event_type'] === 'PAYMENT.CAPTURE.COMPLETED') { validateOrder(); }.

Exemple réaliste (mini‑scénario) : un attaquant cible une boutique vendant des produits dématérialisés (licences, PDF, billetterie). Il observe (ou devine) l’URL du webhook (elle fuit parfois via un dépôt, un email de support, un log d’erreur, ou un module trop bavard). Il envoie un POST imitant PAYMENT.CAPTURE.COMPLETED avec un resource contenant un montant plausible. Si votre code se contente de “l’événement dit payé → je livre”, il récupère le contenu instantanément, sans transaction PayPal réelle.

Le second risque est le replay (rejeu). Même si l’attaquant ne peut pas forger la signature, un enregistrement de requête (via logs applicatifs, reverse proxy mal configuré, fuite de traces) peut être rejoué si vous ne mettez pas d’idempotence : PayPal peut aussi renvoyer un événement (retries réseau), donc votre code doit être conçu pour traiter plusieurs fois le même event_id sans effet secondaire (double validation, double facture, double écriture ERP).

Points à retenir côté rejouabilité :

  • PayPal peut légitimement renvoyer un webhook en cas de timeout réseau ou de réponse non‑2xx.
  • Un webhook n’est pas une “commande unique”, c’est une notification : votre code doit tolérer les doublons.
  • Sans garde‑fou, les effets secondaires sont coûteux : avoirs à traiter, stocks incohérents, expéditions doublées, rapprochement comptable compliqué.

Enfin, il y a les risques “ops” : endpoint exposé sans rate limiting → DoS applicatif ; parsing JSON gourmand → saturation PHP-FPM ; dépendance à une ressource externe au mauvais moment (validation synchrone lente) → timeouts et redelivery en boucle.

Checklist “risques typiques” (à cocher sur un handler existant) :

  • [ ] Le handler accepte et traite un webhook sans validation auprès de PayPal.
  • [ ] L’URL webhook contient un “secret” statique (token) et c’est la seule protection.
  • [ ] Aucune contrainte d’unicité sur event_id (ou équivalent).
  • [ ] Le code met à jour l’état de commande avant toute vérification.
  • [ ] Le handler ne journalise pas (ou journalise trop, avec données sensibles).
  • [ ] Réponse lente (> 5–10 s) → risque de retries en cascade.

Pour l’aspect architecture webhooks et files (traitement asynchrone, retries internes, découplage), vous pouvez recouper avec l’article interne : Intégration ERP e‑commerce : architecture API, webhooks et files RabbitMQ.


Ce que PayPal fournit pour prouver l’authenticité : signatures, en-têtes et endpoint de vérification

PayPal signe ses notifications et fournit des en‑têtes dédiés (noms tels que PAYPAL-TRANSMISSION-ID, PAYPAL-TRANSMISSION-TIME, PAYPAL-TRANSMISSION-SIG, PAYPAL-CERT-URL, PAYPAL-AUTH-ALGO). L’idée n’est pas de “faire confiance” à ces en‑têtes, mais de les réinjecter dans une vérification côté PayPal.

Au moment de la rédaction (2026‑08), l’approche standard documentée par PayPal consiste à appeler l’API Verify Webhook Signature (/v1/notifications/verify-webhook-signature) avec :

  • le webhook_id (l’identifiant du webhook configuré dans votre app PayPal),
  • le corps brut reçu (sans reformatage),
  • les en‑têtes PayPal ci‑dessus.

Référence : documentation officielle PayPal Webhooks (API v1)

Table “à quoi servent les en‑têtes” (utile pour débugger sans inventer de logique maison) :

En-tête PayPal Rôle pratique
PAYPAL-TRANSMISSION-ID Identifiant unique de transmission côté PayPal (utile en support/diagnostic).
PAYPAL-TRANSMISSION-TIME Horodatage de la transmission (sert au mécanisme de signature / anti‑rejeu).
PAYPAL-TRANSMISSION-SIG Signature transmise (ne pas tenter de la recalculer “à la main” si vous utilisez l’endpoint de vérification).
PAYPAL-CERT-URL URL du certificat utilisé par PayPal (résolu/validé côté PayPal lors du “verify”).
PAYPAL-AUTH-ALGO Algorithme (ex. SHA256withRSA) utilisé pour la signature.

Conceptuellement, on est dans de la signature numérique “JWS‑like” : l’intégrité et l’authenticité reposent sur des primitives cryptographiques, pas sur un contrôle d’IP ou un token statique. Le RFC 7515 définit le cadre général :

« JSON Web Signature (JWS) represents content secured with digital signatures or Message Authentication Codes (MACs) using JSON-based data structures. » (RFC 7515, Abstract)
https://www.rfc-editor.org/rfc/rfc7515

PayPal ne vous demande pas de mettre en œuvre JWS manuellement ici : vous utilisez leur endpoint “verify”, ce qui réduit fortement les erreurs (mauvaise canonicalisation JSON, gestion de certificats, etc.).

Important : la vérification de signature prouve que la requête provient de PayPal et que le payload n’a pas été altéré pendant le transport. Elle ne remplace pas les contrôles métier (montant, devise, état attendu, correspondance commande ↔ transaction), qui restent indispensables.


Implémentation PHP côté PrestaShop (8.1/8.2/9.x) : corps brut, validation de signature, idempotence

Contexte versions : les exemples ci‑dessous sont pensés pour PHP 8.2/8.3 (courant sur les stacks 2026) et une boutique PrestaShop 8.1+ ou PrestaShop 9. PrestaShop n’a pas de “gestionnaire de webhooks” natif centralisé : vous passez généralement par un module exposant un endpoint (Front Controller legacy en 1.7/8, ou Controller Symfony en 9 selon votre architecture). Cette absence de standardisation dans le cœur implique de définir vous‑même : (1) le stockage des IDs d’événements traités, (2) la stratégie de logs, (3) la politique de retry interne.

1) Capturer le corps brut sans le modifier

La vérification PayPal dépend du corps brut exact reçu. Évitez de reconstruire le JSON (pretty print, ré-encodage) avant vérification : vous risquez de casser la validation. En PHP, utilisez file_get_contents('php://input') et conservez aussi les en‑têtes.

<?php
// PHP 8.2+
$rawBody = file_get_contents('php://input');
if ($rawBody === false || $rawBody === '') {
    http_response_code(400);
    exit('Empty body');
}

// Normaliser la lecture des headers (getallheaders() peut dépendre du SAPI)
$headers = function_exists('getallheaders') ? getallheaders() : [];
$headers = array_change_key_case($headers, CASE_UPPER);

$required = [
  'PAYPAL-TRANSMISSION-ID',
  'PAYPAL-TRANSMISSION-TIME',
  'PAYPAL-TRANSMISSION-SIG',
  'PAYPAL-CERT-URL',
  'PAYPAL-AUTH-ALGO',
];
foreach ($required as $h) {
  if (empty($headers[$h])) {
    http_response_code(400);
    exit('Missing header: ' . $h);
  }
}

Dans PrestaShop, placez cette logique dans un contrôleur dédié, sans dépendre d’un état de session/cookie. Si vous êtes sur PrestaShop 9 (Symfony), l’équivalent “propre” consiste à utiliser $request->getContent() pour obtenir le corps brut sans passer par un JSON decoder avant vérification.

Côté Nginx/Apache, vérifiez aussi :

  • que l’endpoint n’est pas soumis à une auth front (Basic Auth globale oubliée, WAF trop strict…),
  • que client_max_body_size (Nginx) / LimitRequestBody (Apache) ne bloque pas,
  • que le reverse proxy ne “mange” pas certains headers (rare, mais déjà vu sur des config durcies).

Pour la partie durcissement côté serveur PHP (surface d’attaque, logs, headers, dépendances), l’article Symfony : sécuriser un serveur PHP avec hardening et gestion CVE donne de bons repères (même si votre endpoint n’est pas “Symfony pur”, les principes restent valables).

2) Appeler PayPal “verify-webhook-signature” (vérification serveur à serveur)

Vous devez appeler PayPal avec un token OAuth2 (client_id/secret) puis envoyer la charge de vérification. Ne “validez” jamais un webhook sans avoir un verification_status = SUCCESS.

Endpoints à connaître (prod vs sandbox)

  • Production : https://api-m.paypal.com
  • Sandbox : https://api-m.sandbox.paypal.com

En pratique, vous aurez deux webhook_id distincts (un sandbox, un prod). Évitez de “réutiliser” un id ou de le mettre en dur sans environnement : c’est une cause fréquente de faux négatifs en vérification.

Exemple complet en curl (utile pour isoler un problème)

1) Récupérer un access token :

curl -sS -X POST "https://api-m.paypal.com/v1/oauth2/token" \
  -u "${PAYPAL_CLIENT_ID}:${PAYPAL_SECRET}" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials"

2) Vérifier la signature du webhook (le payload doit contenir les headers reçus, le webhook_id et le corps brut) :

curl -sS -X POST "https://api-m.paypal.com/v1/notifications/verify-webhook-signature" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer ${ACCESS_TOKEN}" \
  -d "{
    \"auth_algo\": \"${PAYPAL_AUTH_ALGO}\",
    \"cert_url\": \"${PAYPAL_CERT_URL}\",
    \"transmission_id\": \"${PAYPAL_TRANSMISSION_ID}\",
    \"transmission_sig\": \"${PAYPAL_TRANSMISSION_SIG}\",
    \"transmission_time\": \"${PAYPAL_TRANSMISSION_TIME}\",
    \"webhook_id\": \"${YOUR_WEBHOOK_ID}\",
    \"webhook_event\": ${RAW_JSON_BODY}
  }"

Points d’attention :

  • webhook_event est un objet JSON, pas une chaîne : en shell, ça implique de gérer soigneusement l’échappement. Pour débugger, le plus simple est souvent de mettre le JSON dans un fichier et d’utiliser -d @payload.json.
  • Le RAW_JSON_BODY doit être le JSON tel que reçu (même structure, mêmes champs). D’où l’importance de conserver $rawBody.

Exemple PHP (cURL natif) : vérifier et refuser par défaut

Ci‑dessous, une approche volontairement “safe by default” : si la vérif échoue ou si PayPal est indisponible, vous ne validez rien (et vous renvoyez un code approprié pour déclencher un retry PayPal si nécessaire).

<?php

function paypalRequest(string $url, string $method, array $headers, ?string $body, int $timeoutSeconds = 10): array {
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_POSTFIELDS     => $body,
        CURLOPT_TIMEOUT        => $timeoutSeconds,
    ]);
    $respBody = curl_exec($ch);
    $status   = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    if ($respBody === false) {
        $err = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException("cURL error: ".$err);
    }
    curl_close($ch);

    $json = json_decode($respBody, true);
    return [$status, $json, $respBody];
}

// 1) access token
[$status, $tokenJson, $tokenRaw] = paypalRequest(
    'https://api-m.paypal.com/v1/oauth2/token',
    'POST',
    [
        'Content-Type: application/x-www-form-urlencoded',
        'Authorization: Basic '.base64_encode(getenv('PAYPAL_CLIENT_ID').':'.getenv('PAYPAL_SECRET')),
    ],
    'grant_type=client_credentials'
);

if ($status !== 200 || empty($tokenJson['access_token'])) {
    http_response_code(502);
    exit('PayPal auth failed');
}

$accessToken = $tokenJson['access_token'];

// 2) verify signature
$payload = [
    'auth_algo'          => $headers['PAYPAL-AUTH-ALGO'],
    'cert_url'           => $headers['PAYPAL-CERT-URL'],
    'transmission_id'    => $headers['PAYPAL-TRANSMISSION-ID'],
    'transmission_sig'   => $headers['PAYPAL-TRANSMISSION-SIG'],
    'transmission_time'  => $headers['PAYPAL-TRANSMISSION-TIME'],
    'webhook_id'         => getenv('PAYPAL_WEBHOOK_ID'),
    'webhook_event'      => json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR),
];

[$vStatus, $verifyJson, $verifyRaw] = paypalRequest(
    'https://api-m.paypal.com/v1/notifications/verify-webhook-signature',
    'POST',
    [
        'Content-Type: application/json',
        'Authorization: Bearer '.$accessToken,
    ],
    json_encode($payload, JSON_UNESCAPED_SLASHES)
);

if ($vStatus !== 200 || ($verifyJson['verification_status'] ?? null) !== 'SUCCESS') {
    // Refuser par défaut (ne pas valider la commande)
    http_response_code(400);
    exit('Invalid webhook signature');
}

// À ce stade : authentique, vous pouvez passer aux contrôles métier + idempotence.

Optimisations recommandées en production :

  • Mettre en cache l’access token (TTL généralement court, typiquement minutes) pour éviter un appel OAuth à chaque webhook.
  • Fixer un timeout strict (p.ex. 5–10 s) : si PayPal est lent, mieux vaut laisser PayPal retenter plutôt que bloquer vos workers PHP-FPM.
  • Journaliser uniquement des métadonnées (ex. event_id, transmission_id, statut de vérif), pas des données personnelles.

Pour une vision plus “sécurité API” (modèles de menace, contrôles de base), OWASP maintient un référentiel utile : OWASP API Security project

3) Idempotence : traiter 1 événement = 1 effet, même en cas de retries

Une fois la signature vérifiée, l’autre pilier est l’idempotence. Le bon pattern : verrouiller sur un identifiant stable fourni par PayPal (souvent event.id dans le corps du webhook) et empêcher tout traitement double.

Approche simple et robuste : une table dédiée avec contrainte d’unicité.

Exemple de structure logique :

  • paypal_event_id (unique)
  • event_type
  • received_at
  • processed_at (nullable)
  • processing_status (received, processed, failed)
  • order_reference / id_order (si corrélé)
  • éventuellement payload_hash (utile en forensic sans stocker tout le JSON)

Stratégie de traitement :

  1. Vous parsez le JSON après validation de signature.
  2. Vous tentez d’insérer event_id en base dans une transaction.
  3. Si insertion échoue (duplicate key), vous répondez 200 OK immédiatement : déjà traité.
  4. Sinon, vous continuez le traitement métier, puis marquez processed_at.

Ce pattern résout à la fois :

  • les retries PayPal,
  • les doubles livraisons en cas de timeout côté serveur,
  • les rejouements via logs si quelqu’un récupère un ancien payload.

4) Contrôles métier minimaux (même quand la signature est valide)

Une signature valide ne garantit pas que vous devez accepter l’action.

Contrôles recommandés (souvent oubliés) :

  • Corrélation commande ↔ transaction : le webhook doit référencer une ressource PayPal que vous avez vous‑même créée (ex. order_id, capture_id) et associée à une commande PrestaShop.
  • Montant & devise : vérifiez que la devise et le montant correspondent à la commande (et pas seulement “> 0”).
  • Statut attendu : n’acceptez pas n’importe quel event_type comme déclencheur de validation de commande. Typiquement :
  • CHECKOUT.ORDER.APPROVED ≠ paiement capturé (souvent “autorisé/validé côté PayPal”, mais pas forcément encaissé selon votre flow).
  • PAYMENT.CAPTURE.COMPLETED est plus souvent le bon déclencheur pour “payé”.
  • Contexte environnement : un webhook Sandbox reçu sur un endpoint Prod ne doit jamais toucher des commandes réelles (d’où l’importance de séparer endpoints/config).

Une pratique très solide (au prix d’un appel API supplémentaire) : une fois le webhook authentifié, re-lire l’objet côté PayPal (GET sur l’order/capture) et comparer les champs clés (montant, currency, seller/merchant, statut). C’est particulièrement utile si vous avez des règles tarifaires, des split payments, ou si vous voulez une piste d’audit plus forte en cas de litige.

5) Réponse HTTP, performance, logs : éviter les boucles et les angles morts

PayPal attend une réponse HTTP : si vous répondez lentement ou en erreur, PayPal peut retenter. Donc :

  • Répondez 2xx uniquement quand vous avez :
  • vérifié la signature et
  • soit traité l’événement, soit enregistré l’événement (idempotence) pour traitement asynchrone.
  • Répondez 4xx quand la requête est invalide (headers manquants, signature invalide).
  • Répondez 5xx si vous souhaitez explicitement un retry (panne DB, indisponibilité interne), mais attention : un 5xx systématique peut provoquer une tempête de retries.

Côté performance, un bon compromis consiste à :

  • faire la vérification de signature,
  • écrire l’événement en base (idempotence),
  • pousser un job (file, cron, worker),
  • répondre rapidement 200 OK,
  • traiter ensuite la logique métier (update commande, facture, ERP…).

Enfin, sur les logs : en France/UE, les logs d’un webhook de paiement peuvent contenir des identifiants, emails, adresses selon le payload et votre instrumentation. Sans entrer dans du juridique, appliquez au minimum une hygiène “RGPD‑compatible” :

  • minimiser ce qui est loggé (pas de payload complet par défaut),
  • limiter la rétention (rotation),
  • contrôler l’accès (les logs sont une cible fréquente).

À lire aussi