Tutoriel · PayPal · Spring Boot

Recevoir les webhooks PayPal dans Spring Boot sans en perdre

Vérification de signature sur le corps brut, réponse immédiate, dédoublonnage par identifiant d’événement et traitement différé, ordonné et repris en cas d’échec.

Ce que garantit PayPal, et ce qu’il ne garantit pas

Pour le middleware du premier article, les webhooks sont la source principale d’information : capture terminée, refusée, remboursée, litige ouvert. PayPal envoie chaque événement en POST à l’URL déclarée dans l’application et considère la livraison réussie à la réception d’un statut 2xx. Sinon, il réessaie pendant plusieurs jours.

D’où trois règles :

  • un événement peut arriver plusieurs fois : le traitement doit être idempotent ;
  • il peut arriver dans le désordre : un remboursement avant la capture, en cas de reprise ;
  • la réponse doit être rapide : un traitement long dans la requête provoque des renvois et des doublons.

Et une quatrième, de sécurité : n’importe qui peut appeler l’URL. Rien n’est pris en compte avant vérification de la signature.

Vérifier la signature

PayPal signe chaque envoi et transmet la signature dans des en-têtes PAYPAL-TRANSMISSION-*. La vérification la plus simple consiste à demander à PayPal lui-même, par l’API verify-webhook-signature, en lui renvoyant ces en-têtes, l’identifiant du webhook déclaré et l’événement reçu :

@Component
public class PayPalSignatureVerifier {

    private final RestClient http;
    private final PayPalProperties props;
    private final ObjectMapper json;

    private record Verification(String verification_status) {}

    public PayPalSignatureVerifier(RestClient.Builder b, PayPalProperties props,
                                   PayPalTokenProvider tokens, ObjectMapper json) {
        this.props = props;
        this.json = json;
        this.http = b.baseUrl(props.baseUrl().toString())
                .requestInterceptor((req, body, exec) -> {
                    req.getHeaders().setBearerAuth(tokens.token());
                    return exec.execute(req, body);
                })
                .build();
    }

    public boolean isValid(HttpHeaders h, String rawBody) throws JsonProcessingException {
        ObjectNode demande = json.createObjectNode()
                .put("auth_algo", h.getFirst("PAYPAL-AUTH-ALGO"))
                .put("cert_url", h.getFirst("PAYPAL-CERT-URL"))
                .put("transmission_id", h.getFirst("PAYPAL-TRANSMISSION-ID"))
                .put("transmission_sig", h.getFirst("PAYPAL-TRANSMISSION-SIG"))
                .put("transmission_time", h.getFirst("PAYPAL-TRANSMISSION-TIME"))
                .put("webhook_id", props.webhookId());
        // L'événement est réinjecté tel que reçu : le désérialiser puis le resérialiser
        // (ordre des champs, format des nombres) ferait échouer la vérification.
        demande.putRawValue("webhook_event", new RawValue(rawBody));

        Verification v = http.post().uri("/v1/notifications/verify-webhook-signature")
                .contentType(MediaType.APPLICATION_JSON)
                .body(json.writeValueAsString(demande))
                .retrieve()
                .body(Verification.class);
        return v != null && "SUCCESS".equals(v.verification_status());
    }
}

Le piège classique : désérialiser l’événement en objet Java, puis le resérialiser pour l’envoyer. Les champs changent d’ordre, un montant 10.50 peut devenir 10.5, et la vérification échoue. Le corps brut est inséré tel quel avec putRawValue.

Cet appel coûte une requête par événement. Une vérification locale est possible (contrôle du certificat PayPal, puis de la signature sur l’identifiant de transmission, l’horodatage, l’identifiant du webhook et le CRC32 du corps), au prix d’un code cryptographique plus délicat à maintenir.

Recevoir : vérifier, stocker, répondre

Le contrôleur ne fait que trois choses : vérifier, enregistrer l’événement brut, répondre 200. Aucun appel à Salesforce.

@RestController
public class PayPalWebhookController {

    private final PayPalSignatureVerifier verifier;
    private final EvenementRepository evenements;
    private final ObjectMapper json;

    // Constructeur omis.

    // Le corps est lu en String : c'est cette chaîne exacte qui sert à la vérification.
    @PostMapping(path = "/webhooks/paypal", consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Void> recevoir(@RequestHeader HttpHeaders headers, @RequestBody String raw)
            throws JsonProcessingException {
        if (!verifier.isValid(headers, raw)) {
            return ResponseEntity.badRequest().build();
        }
        JsonNode evt = json.readTree(raw);
        try {
            evenements.saveAndFlush(Evenement.recu(
                    evt.path("id").asText(),
                    evt.path("event_type").asText(),
                    raw));
        } catch (DataIntegrityViolationException doublon) {
            // Déjà reçu (même identifiant) : PayPal a renvoyé l'événement. On acquitte sans retraiter.
        }
        return ResponseEntity.ok().build();
    }
}

Le corps est déclaré en String pour conserver les octets reçus. L’identifiant de l’événement est la clé primaire de la table : un renvoi par PayPal viole la contrainte d’unicité, et le contrôleur acquitte simplement. C’est le dédoublonnage, sans lecture préalable ni condition de course.

Une file d’événements en base

Pour un volume de paiements modéré, une table suffit comme file : pas de courtier de messages à exploiter, et l’événement est stocké dans la même base que son état de traitement.

@Entity
@Table(name = "paypal_evenement")
public class Evenement {

    @Id
    private String id;                 // WH-… : identifiant PayPal, clé primaire = dédoublonnage
    private String type;               // PAYMENT.CAPTURE.COMPLETED…
    @Column(columnDefinition = "jsonb")
    @JdbcTypeCode(SqlTypes.JSON)
    private String payload;
    @Enumerated(EnumType.STRING)
    private Statut statut;             // RECU, TRAITE, ECHEC
    private int tentatives;
    private Instant recuLe;
    private Instant prochainEssai;
    private String derniereErreur;

    public enum Statut { RECU, TRAITE, ECHEC }

    public static Evenement recu(String id, String type, String payload) {
        Evenement e = new Evenement();
        e.id = id;
        e.type = type;
        e.payload = payload;
        e.statut = Statut.RECU;
        e.recuLe = Instant.now();
        e.prochainEssai = e.recuLe;
        return e;
    }
    // Accesseurs et transitions (traite(), echoue(...)) omis.
}

La lecture des événements à traiter verrouille les lignes choisies, en sautant celles qu’une autre instance traite déjà :

public interface EvenementRepository extends JpaRepository<Evenement, String> {

    // SKIP LOCKED : plusieurs instances du middleware se partagent la file sans traiter deux fois la même ligne.
    @Query(value = """
            SELECT * FROM paypal_evenement
            WHERE statut = 'RECU' AND prochain_essai <= now()
            ORDER BY recu_le
            LIMIT :n
            FOR UPDATE SKIP LOCKED
            """, nativeQuery = true)
    List<Evenement> aTraiter(@Param("n") int n);
}

Avec des milliers d’événements par minute, un courtier (RabbitMQ, Kafka) prend le relais ; la logique de dédoublonnage et de reprise reste la même.

Traiter en différé

Une tâche planifiée, activée par @EnableScheduling, prend les événements par paquets de cinquante et les confie au handler de leur type :

@Component
public class EvenementWorker {

    private static final int MAX_TENTATIVES = 8;
    private final EvenementRepository evenements;
    private final Map<String, EvenementHandler> handlers;   // un handler par type d'événement

    // Constructeur omis.

    @Scheduled(fixedDelay = 5_000)
    @Transactional
    public void traiter() {
        for (Evenement e : evenements.aTraiter(50)) {
            EvenementHandler h = handlers.get(e.getType());
            if (h == null) {
                e.traite();                    // type non abonné ou sans intérêt : on l'archive
                continue;
            }
            try {
                h.traiter(e.payloadJson());
                e.traite();
            } catch (SalesforceRejectedException ex) {
                e.echoue(ex.getMessage(), null);          // définitif : alerte, pas de nouvel essai
            } catch (RuntimeException ex) {
                int n = e.getTentatives() + 1;
                Instant suivant = n >= MAX_TENTATIVES ? null : Instant.now().plus(Duration.ofMinutes(1L << n));
                e.echoue(ex.getMessage(), suivant);       // 2, 4, 8… minutes, puis ECHEC
            }
        }
    }
}
  • une erreur passagère reprogramme l’événement avec un délai croissant ;
  • une erreur définitive (donnée refusée par Salesforce) le passe en ECHEC immédiatement : il faut une correction, pas un nouvel essai ;
  • un type sans handler est archivé : on ne s’abonne côté PayPal qu’aux événements utiles, mais un ajout ne doit pas bloquer la file.

Pour un traitement long, mieux vaut valider chaque événement dans sa propre transaction (via TransactionTemplate) plutôt que tout le paquet : un échec en fin de lot ne fait alors pas rejouer les précédents.

Événements dans le désordre

Appliquer aveuglément le contenu de chaque événement expose à un retour en arrière : un PAYMENT.CAPTURE.COMPLETED rejoué après un remboursement remettrait le paiement en « Capturé ». La parade : traiter l’événement comme une simple notification, et relire l’objet à jour chez PayPal.

@Component("PAYMENT.CAPTURE.COMPLETED")
public class CaptureTermineeHandler implements EvenementHandler {

    private final PayPalClient paypal;
    private final SalesforceClient salesforce;

    @Override
    public void traiter(JsonNode evt) {
        String captureId = evt.at("/resource/id").asText();
        // On relit la capture chez PayPal : son état actuel fait foi, pas celui de l'événement.
        Capture c = paypal.capture(captureId);
        salesforce.upsertPaiementParCommande(c.supplementaryData().relatedIds().orderId(), new PaiementSf(
                StatutPaiement.depuis(c.status()),       // COMPLETED → Capture, REFUNDED → Rembourse…
                c.amount().value(),
                captureId));
    }
}

Quel que soit l’ordre d’arrivée, Salesforce reçoit le dernier état connu. Combiné à l’upsert par External ID du deuxième article, le traitement devient rejouable à volonté.

Exploiter

  • Alerter sur les événements en ECHEC et sur l’âge du plus vieil événement RECU : une file qui grossit annonce une panne en aval.
  • Rejouer : repasser un événement en RECU après correction suffit, puisque le traitement est idempotent.
  • Purger les événements traités après quelques mois : la table n’est pas un historique comptable, PayPal et Salesforce le sont.
  • Tester avec le simulateur de webhooks du tableau de bord développeur PayPal, en sandbox, qui envoie des événements d’exemple de chaque type.

Les événements arrivent ; reste à les provoquer. Le prochain article déclenche un paiement depuis Salesforce.

🍪 Gestion des cookies
Ce site utilise des cookies pour mesurer l'audience (Google Analytics) et lire des vidéos (YouTube). Vous pouvez personnaliser votre choix.
Mentions légales
Réglages des cookies
Essentiels Nécessaires au fonctionnement du site — ne peuvent pas être désactivés.
Google Analytics Analyse d'audience (pages vues, sources de trafic). Aucune donnée personnelle identifiante.
YouTube Permet de lire les vidéos YouTube intégrées sur cette page.