Blog

Intégrations 10 min de lecture

Webhooks Stripe en PHP : un gestionnaire fiable auquel vous pouvez faire confiance

Un webhook Stripe qui fonctionne sur votre ordinateur portable n'est pas encore un webhook auquel vous pouvez faire confiance avec de l'argent. Ce guide montre comment vérifier les signatures, accuser réception rapide, dédupliquer les événements et gérer les tentatives en PHP et Laravel simples, avec du code que vous pouvez coller dans un projet.

Illustration de couverture : Webhooks Stripe en PHP : un gestionnaire fiable auquel vous pouvez faire confiance

En bref

  • Considérez le webhook, et non la redirection du navigateur, comme la source de vérité sur l'état du paiement.
  • Lisez le corps brut de la requête et vérifiez l'en-tête Stripe-Signature avant de toucher la charge utile.
  • Renvoyez rapidement une réponse 2xx et effectuez le vrai travail dans une file d'attente ou après avoir stocké l'événement.
  • Stockez l'ID de l'événement avec un index unique : Stripe peut diffuser le même événement plusieurs fois et dans le désordre.
  • Testez avec la Stripe CLI (stripe listen et stripe trigger) avant de passer en direct.

Si vous acceptez les paiements avec Stripe, les webhooks Stripe en PHP sont la partie qui décide si les commandes sont marquées comme payées, les abonnements sont activés et les remboursements sont reflétés dans votre base de données. La page de paiement n'est que la porte d'entrée : les clients ferment leurs onglets, les banques confirment en retard et certains modes de paiement sont réglés quelques minutes ou jours plus tard. Le webhook est le message de Stripe qui raconte ce qui s'est réellement passé.

Nous construisons et maintenons des intégrations de paiement pour les clients et pour nos propres produits (par exemple Bill, qui accepte les paiements Stripe et PayPal sur une page de paiement publique). Cet article est la liste de contrôle que nous suivons lorsque nous écrivons un point de terminaison de webhook Stripe en PHP.

Comment fonctionnent les webhooks Stripe en une minute

Vous enregistrez une URL HTTPS dans le tableau de bord Stripe (ou via l'API) et choisissez les types d'événements à recevoir. Lorsque quelque chose se produit (une session de paiement se termine, une facture est payée, des frais sont remboursés) Stripe envoie une requête POST avec un objet d'événement JSON à cette URL. Votre serveur doit répondre avec un code d'état 2xx. Si ce n'est pas le cas, Stripe réessaye avec une interruption exponentielle pendant trois jours maximum en mode direct.

Trois faits façonnent l’ensemble de la conception :

  • Les événements peuvent arriver plusieurs fois. Les tentatives et les problèmes de réseau rendent les doublons normaux.
  • Les événements peuvent arriver dans le désordre. Vous pouvez voir invoice.paid avant customer.subscription.created.
  • N'importe qui peut POSTER sur votre URL. Sans vérification de signature, un attaquant peut marquer sa propre commande comme payée.

Étape 1. Installez le SDK et créez le point de terminaison

Utilisez la bibliothèque officielle, stripe/stripe-php, et gardez le point de terminaison secret (il commence par whsec_) dans une variable d'environnement, jamais dans le référentiel.

composer require stripe/stripe-php

# .env
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...

Chaque point de terminaison de webhook dans le tableau de bord a son propre secret de signature, et le secret de stripe listen en développement est encore différent. Un bug très courant « échec de la vérification de la signature » consiste simplement à utiliser le mauvais.

Étape 2. Vérifiez la signature sur le corps brut

Stripe signe chaque demande. L'en-tête Stripe-Signature contient un horodatage et une ou plusieurs signatures calculées comme un HMAC-SHA256 de timestamp.payload avec le secret de votre point de terminaison. La bibliothèque vérifie la signature et rejette par défaut les événements datant de plus de cinq minutes.

Le détail important : la vérification nécessite les octets bruts exacts envoyés Stripe. Si un framework analyse le JSON et que vous le ré-encodez, les espaces changent et la signature ne correspond plus.

<?php
// public/webhooks/stripe.php (plain PHP)
require __DIR__ . '/../../vendor/autoload.php';

$payload   = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
$secret    = getenv('STRIPE_WEBHOOK_SECRET');

try {
    $event = \Stripe\Webhook::constructEvent($payload, $sigHeader, $secret);
} catch (\UnexpectedValueException $e) {
    http_response_code(400);   // invalid JSON
    exit;
} catch (\Stripe\Exception\SignatureVerificationException $e) {
    http_response_code(400);   // bad or missing signature
    exit;
}

Dans Laravel, lisez $request->getContent() au lieu de $request->all() et excluez la route de la protection CSRF (les webhooks ne portent pas de jeton CSRF ; la signature est la protection). Placez l'itinéraire en dehors du groupe middleware de session web si vous le pouvez.

Étape 3. Rendre le gestionnaire idempotent

Idempotent signifie : traiter deux fois le même événement donne le même résultat que le traiter une fois. L'approche fiable la plus simple consiste à créer un tableau des ID d'événements traités avec un index unique. Insérez d'abord ; si l'insertion échoue parce que l'ID existe, vous avez déjà vu l'événement.

CREATE TABLE stripe_events (
  id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  event_id      VARCHAR(64) NOT NULL,
  type          VARCHAR(100) NOT NULL,
  payload       JSON NOT NULL,
  status        ENUM('received','processed','failed') NOT NULL DEFAULT 'received',
  received_at   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  processed_at  TIMESTAMP NULL,
  UNIQUE KEY uniq_event (event_id)
);
try {
    $pdo->prepare('INSERT INTO stripe_events (event_id, type, payload) VALUES (?, ?, ?)')
        ->execute([$event->id, $event->type, $payload]);
} catch (PDOException $e) {
    if ($e->getCode() === '23000') {   // duplicate key: already received
        http_response_code(200);
        exit;
    }
    throw $e;
}

Le stockage de la charge utile brute vous donne une piste d'audit : lorsqu'un client demande « pourquoi ai-je été facturé deux fois ? », vous pouvez rejouer exactement ce que Stripe vous a dit. Il permet également de retraiter les événements ayant échoué après avoir corrigé un bug.

Étape 4. Accusez réception rapidement, traitez séparément

Stripe attend une réponse dans quelques secondes. Si votre gestionnaire envoie des e-mails, appelle d'autres API ou génère des PDF en ligne, le délai expirera, Stripe réessayera et vous refaire le même travail. Faites le minimum dans la requête - vérifier, stocker, répondre 200 - et exécuter la vraie logique dans un travailleur de file d'attente (files d'attente Laravel, Symfony Messenger, une tâche cron lisant status = 'received' ou un travailleur géré par un superviseur).

Si l'événement ne peut pas être stocké du tout (base de données en panne), renvoie un statut 5xx. C'est le seul cas où vous souhaitez que Stripe réessaye plus tard.

Étape 5. Gérez les événements importants

Vous n'avez pas besoin de tous les types d'événements. Abonnez-vous uniquement à ce que votre logique métier utilise ; moins d'abonnements signifie moins de bruit et une surface d'attaque plus petite. Un ensemble typique :

  • checkout.session.completed - le client a terminé le paiement. Recherchez votre commande à l'aide du client_reference_id ou des métadonnées que vous avez définies lors de la création de la session.
  • payment_intent.succeeded et payment_intent.payment_failed - pour les flux de paiement personnalisés.
  • invoice.paid et invoice.payment_failed - facturation des abonnements.
  • customer.subscription.updated et customer.subscription.deleted - modifications et annulations du plan.
  • charge.refunded et charge.dispute.created - l'argent repart.
switch ($event->type) {
    case 'checkout.session.completed':
        $session = $event->data->object;
        $orderId = $session->client_reference_id;
        if ($session->payment_status === 'paid') {
            markOrderPaid($orderId, $session->payment_intent);
        }
        break;

    case 'invoice.payment_failed':
        notifyBillingProblem($event->data->object->customer);
        break;

    case 'charge.refunded':
        markOrderRefunded($event->data->object->payment_intent);
        break;

    default:
        // unknown or unsubscribed type: acknowledge and ignore
        break;
}

Deux habitudes évitent la plupart des bugs. Tout d'abord, ne vous fiez jamais aux montants du navigateur: comparez le montant et la devise payés lors de l'événement avec la commande dans votre base de données. Deuxièmement, parce que les événements peuvent être dans le désordre, rendez les changements d'état monotones : ne déplacez pas une commande de "payée" à "en attente" parce qu'un événement plus ancien est arrivé en retard. En cas de doute, récupérez l'objet actuel depuis l'API au lieu de vous fier à l'instantané de l'événement.

Étape 6. Testez localement avec Stripe CLI

La Stripe CLI transmet les événements réels en mode test à votre machine, vous n'avez donc pas besoin d'un tunnel public :

stripe login
stripe listen --forward-to http://localhost:8000/webhooks/stripe
# in another terminal:
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed

stripe listen imprime un secret whsec_ temporaire. Mettez cela dans votre .env local. Testez également volontairement les chemins malheureux : envoyez une requête avec une signature incorrecte (attendez-vous à 400), envoyez le même événement deux fois (attendez-vous à un effet de base de données) et arrêtez votre gestionnaire de file d'attente pendant une minute pour voir que les événements s'accumulent au lieu de disparaître.

Erreurs courantes que nous constatons dans les audits

  • Utiliser la redirection comme preuve de paiement. La page success_url peut être ouverte par n’importe qui ; affichez "confirmation de votre paiement" jusqu'à l'arrivée du webhook.
  • Analyse de JSON avant vérification. Les corps réencodés brisent la signature ; les analyseurs de corps de framework sont un coupable fréquent.
  • Un secret pour tester et vivre. Les modes test et live ont des clés distinctes et des points de terminaison de webhook distincts.
  • Pas de journalisation. Sans événements stockés et sans canal d'échec visible, les bugs de paiement sont détectés par les clients.
  • Manipulateurs lents. Tout ce qui dépasse quelques secondes déclenche des tentatives et des travaux en double.
  • Blocage Stripe au niveau du pare-feu ou du WAF. Si vous utilisez Cloudflare ou un autre WAF, autorisez le chemin du webhook et vérifiez que les règles du bot ne renvoient pas 403 aux serveurs de Stripe.

Surveillance : comment savoir si elle continue de fonctionner

Un webhook peut tomber en panne des mois après son lancement : une modification déployée renomme une colonne, un secret est alterné, un certificat expire. Le tableau de bord Stripe affiche les tentatives de livraison et les échecs par point de terminaison, et Stripe vous envoie un e-mail lorsqu'un point de terminaison continue d'échouer. De votre côté, alertez sur trois choses : un nombre croissant d'événements en received ou failed, une baisse brutale des événements par heure et tout taux 4xx ou 5xx sur la route du webhook. Un petit travail de rapprochement quotidien qui compare les commandes payées dans votre base de données avec la liste des paiements réussis de Stripe rattrape le reste.

Comment pouvons-nous vous aider

Nous concevons et construisons des intégrations de paiement en production et de webhooks avec des contrôles de signature, l'idempotence, des tentatives et des journaux que votre équipe peut lire. Si vous disposez déjà d'un flux Stripe et souhaitez avoir une deuxième paire d'yeux, une révision ou une consultation d'architecture est le moyen le plus rapide de trouver les points faibles. Pour une vue d'ensemble du déplacement des événements critiques hors de Zapier ou Make, lisez when no-code breaks, et pour un autre fournisseur, consultez notre guide d' intégration de WayForPay en PHP.

Questions fréquentes

Pourquoi la vérification de la signature du webhook Stripe échoue-t-elle ?

Les causes habituelles sont l'utilisation d'un mauvais secret de signature (test vs live, CLI vs point de terminaison du tableau de bord), la vérification d'un corps analysé ou réencodé au lieu du corps brut de la requête, ou un proxy qui modifie la charge utile. Vérifiez les octets bruts exacts avec le propre secret whsec_ du point de terminaison.

Comment puis-je éviter de traiter deux fois un événement Stripe ?

Stockez chaque ID d'événement dans une table avec un index unique et insérez-le avant d'effectuer tout travail. Si l'insertion échoue avec une clé en double, renvoyez 200 et ignorez le traitement. Assurez également que l’action commerciale elle-même peut être répétée en toute sécurité.

Quel code de réponse un webhook Stripe doit-il renvoyer ?

Renvoyez un code 2xx (200 ou 204) dès que l'événement est vérifié et stocké. Renvoyez 400 pour une signature invalide et 5xx uniquement lorsque vous n'avez pas pu stocker l'événement et souhaitez que Stripe réessaye.

Combien de temps Stripe réessaye-t-il les webhooks ayant échoué ?

En mode direct Stripe réessaye avec une interruption exponentielle pendant trois jours maximum. En mode test, la fenêtre de nouvelle tentative est beaucoup plus courte, vous ne devez donc pas vous fier aux nouvelles tentatives en mode test pour juger du comportement de production.

Puis-je tester les webhooks Stripe sans URL publique ?

Oui. Exécutez stripe Listen --forward-to avec la Stripe CLI pour transmettre les événements de test à localhost et utilisez le déclencheur Stripe pour générer des exemples d'événements.

Appel découverte gratuit

Apportez une pile désordonnée ou une intégration bloquée - Integrono tracera le premier pas le plus intelligent.

Vous obtenez un avis senior sur les pièges budgétaires, les dépendances et s'il faut acheter, assembler ou construire - avant le début du travail payant. Le PDG reste dans la boucle.