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
ECHECimmé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
ECHECet sur l’âge du plus vieil événementRECU: une file qui grossit annonce une panne en aval. - Rejouer : repasser un événement en
RECUaprè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.