Webhooks paiement externe — HelloAsso & PayZen
Kohinos accepte les paiements en euros via deux gateways : HelloAsso (cotisations MLCC, dons, adhésion) et PayZen (rechargement eMLc par carte bancaire, cotisation SSA). Dans les deux cas, la confirmation de paiement arrive de façon asynchrone par un webhook serveur-à-serveur. Tant que le webhook n'a pas été reçu, validé et persisté, aucune monnaie locale n'est créditée — l'opération côté Kohinos est en attente. C'est ce contrat de fiabilité que couvre cette page : signature, idempotence, atomicité, ordre des effets.
Flux HelloAsso (Order/Payment)
sequenceDiagram
autonumber
participant Adh as Adhérent
participant HA as HelloAsso
participant Ctl as HelloAssoCallbackController
participant Auth as HelloAssoWebhookAuthenticator
participant Pay as HelloAssoPaymentService
participant DB
participant Mail as Mailer (subscriber)
Adh->>HA: Paiement formulaire (Checkout / Membership)
HA->>Ctl: POST /helloasso/callback/{webhookToken}
Ctl->>Auth: isAuthorised(webhookToken)
Note over Auth: hash_equals(secret URL)
Auth-->>Ctl: true
Ctl->>Pay: getOrCreateHelloAsso(helloassoid, data)
Note over Pay: findOneBy(['helloassoid'=>…])
alt déjà connu (rejeu)
Pay-->>Ctl: HelloAsso existant
Ctl-->>HA: 200 OK (no-op)
else première occurrence
Pay->>DB: wrapInTransaction Flux + Operation
Pay->>DB: setHelloassoid + persist
Ctl->>Mail: dispatch (event subscriber)
Ctl-->>HA: 200 OK
end
Le webhookToken de l'URL fait office de secret partagé : HelloAsso v5
ne signe pas cryptographiquement ses notifications, le token sert de
HMAC dégradé côté URL (cf. HelloAssoWebhookAuthenticator).
Flux PayZen (IPN via Payum)
sequenceDiagram
autonumber
participant Adh as Adhérent
participant PZ as PayZen
participant Ctl as FluxController::doneAction
participant Pum as Payum
participant Gw as PayzenGateway
participant DB
Adh->>PZ: Paiement CB sur formulaire hébergé
par retour navigateur
PZ-->>Adh: redirect /payment/done/?payum_token=…
and IPN serveur-à-serveur
PZ->>Ctl: POST /payment/done/ (kr-hash + payum_token)
end
Ctl->>Pum: httpRequestVerifier->verify(request)
Note over Pum: vérifie payum_token (one-shot)
Pum-->>Ctl: token
Ctl->>Gw: execute(new Notify(token))
Note over Gw: vérifie kr-hash (HMAC-SHA256)
Gw->>DB: maj Payment.status
Ctl->>Gw: execute(new GetHumanStatus(token))
alt STATUS_CAPTURED / AUTHORIZED
Ctl->>Pum: httpRequestVerifier->invalidate(token)
Ctl->>DB: deserialize Flux + persist (wrapInTransaction)
else canceled / failed
Ctl->>Pum: invalidate(token)
end
Ctl-->>PZ: 200 OK
Côté PayZen, l'idempotence repose sur deux éléments : le payum_token
à usage unique invalidé après traitement, et le statut Payment::STATUS_CAPTURED
en BDD (rejeu ⇒ early return).
Comparatif HelloAsso vs PayZen
| Aspect | HelloAsso | PayZen |
|---|---|---|
| URL de callback | POST /helloasso/callback/{webhookToken} |
POST /payment/done/ (IPN + retour navigateur) |
| Contrôleur | HelloAssoCallbackController::callback() |
FluxController::doneAction() |
| Méthode d'authentification | Secret partagé dans l'URL, hash_equals() en temps constant |
kr-hash HMAC-SHA256 vérifié par PayzenGateway via Payum |
| HMAC natif fourni par la plateforme | Non (HelloAsso v5) — d'où le token URL | Oui (kr-hash sur l'ensemble des champs kr_*) |
| Clé d'idempotence stockée | helloasso.helloassoid (int, ID de l'order HelloAsso) |
payum_token + payment.status |
| Format du payload | JSON : { eventType: "Order"|"Payment", data: {...} } |
application/x-www-form-urlencoded, champs kr_* |
| Filtre métier complémentaire | HelloAssoFormFilter::isFormAllowed($formSlug) |
Aucun (le payum_token lie déjà au Payment local) |
| Comportement de retry de la plateforme | Rejeux automatiques tant que la réponse HTTP n'est pas 2xx |
Rejeux limités, configurables côté back-office PayZen |
| Entité d'audit | App\Entity\HelloAsso (un par order) |
App\Entity\PaymentToken + Payment (Payum) |
| Canal Monolog dédié | helloasso (cf. $helloassoLogger) |
payum (factorisé avec autres gateways Payum) |
Règles dures (à ne pas négocier)
⚠ Invariants webhook
-
Authentification AVANT tout traitement. Aucune ligne ne touche la
base avant que
HelloAssoWebhookAuthenticator::isAuthorised()(HelloAsso) ouhttpRequestVerifier->verify()+Notify(PayZen) aient validé l'origine. En cas d'échec, répondre404(HelloAsso) ou400— jamais une page exposant l'existence de l'endpoint. -
Idempotence AVANT création du
Flux. Pour HelloAsso :$helloAssoRepository->findOneBy(['helloassoid' => $id])doit retournernullsinon le webhook répond200 OKsans rejouer l'écriture. Pour PayZen : la combinaison token Payum invalidé + status capturé empêche le rejeu. -
Toute création de
Fluxdans$em->wrapInTransaction()avecLockMode::PESSIMISTIC_WRITEsur les comptes crédités/débités. Référence :SsaRedistributionService(cf. SSA et invariants financiers). -
Réponse rapide (< 5 s). L'envoi d'email, la génération PDF, la mise
à jour de cache front, etc. sont délégués à un EventSubscriber Symfony
écoutant l'événement métier (
FluxCreatedEvent), jamais traités synchrones dans le contrôleur de webhook. -
Jamais de modification de
flux.hashaprès création. Le hash chaîne les flux dans l'ordre. Un webhook rejoué qui muterait un flux existant casserait la chaîne cryptographique — cf. cryptographie. -
Aucun
flush()dans un lifecycle Doctrine (postPersist,postUpdate). Tous les événements métier passent par leEventDispatcherSymfony, jamais par les callbacks ORM.
Anti-patterns observés
⛔ À ne pas reproduire
-
Créer le
Fluxavant la vérif signature. Une faille triviale qui permet à n'importe qui d'injecter de la monnaie locale en rejouant un payload forgé. -
Utiliser la
referencemétier comme clé d'idempotence. Le pattern"Cotisation MLC 2026"n'est ni unique ni stable : deux adhérents légitimes peuvent générer la même reference. Idempotence stricte uniquement surhelloassoid(HelloAsso) oupayum_token(PayZen). -
Faire le
flush()dans unEventSubscriberDoctrine lifecycle. Double-flush ⇒ comportement non-déterministe et risque de race condition entre le webhook et un retry simultané. -
Répondre
200 OKaprès une erreur silencieuse. Si la signature est invalide, répondre401/404. Mais si la signature est valide et que le formulaire HelloAsso n'est pas configuré sur cette instance, répondre200 OK(cf.HelloAssoFormFilter) pour stopper les retries inutiles. La nuance compte. - Logger le payload complet sans masquage. Les payloads HelloAsso contiennent l'email, le nom, l'IP. Sortie Monolog filtrée obligatoire.
-
Reprocesser un
helloassoidconnu avec un montant différent. Considérer comme une tentative de fraude : logger enerror, alerter, mais ne pas modifier leFluxexistant.
Tests de webhooks
Payload HelloAsso de référence
{
"eventType": "Order",
"data": {
"id": 123456789,
"formSlug": "adhesion-2026",
"formType": "Membership",
"payer": {
"email": "alice@example.org",
"firstName": "Alice",
"lastName": "Durand"
},
"amount": { "total": 2000, "vat": 0 },
"items": [
{ "id": 987, "type": "Membership", "amount": 2000 }
]
}
}
Rejouer en local (instance dev, token webhook configuré dans .env.local) :
WEBHOOK_TOKEN="${HELLOASSO_WEBHOOK_TOKEN}"
curl -i -X POST "http://kohinos.local/helloasso/callback/${WEBHOOK_TOKEN}" \
-H 'Content-Type: application/json' \
--data @tests/fixtures/helloasso/order_membership.json
Tester l'idempotence (rejeu identique)
# 1er envoi → 200, Flux créé
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST "http://kohinos.local/helloasso/callback/${WEBHOOK_TOKEN}" \
-H 'Content-Type: application/json' \
--data @tests/fixtures/helloasso/order_membership.json
# → 200
# 2e envoi (même helloassoid) → 200, AUCUN nouveau Flux
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST "http://kohinos.local/helloasso/callback/${WEBHOOK_TOKEN}" \
-H 'Content-Type: application/json' \
--data @tests/fixtures/helloasso/order_membership.json
# → 200
# Vérification SQL : un seul flux pour cet order
mysql -e "SELECT COUNT(*) FROM helloasso WHERE helloassoid = 123456789;"
# → 1
Payload PayZen de référence (IPN)
kr-answer-type=V4/Payment&
kr-answer={"orderDetails":{"orderId":"abc123"},"transactions":[{...,"status":"PAID"}]}&
kr-hash=9e1c2f4a7b...&
kr-hash-algorithm=sha256_hmac&
kr-hash-key=hmacsha256
Rejouer un IPN PayZen (token Payum encore valide) :
curl -i -X POST "http://kohinos.local/payment/done/?payum_token=${TOKEN}" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode "kr-answer-type=V4/Payment" \
--data-urlencode "kr-answer=@tests/fixtures/payzen/payment_paid.json" \
--data-urlencode "kr-hash=${VALID_HMAC}" \
--data-urlencode "kr-hash-algorithm=sha256_hmac"
Astuce — outillage de test
- Fixtures :
tests/fixtures/helloasso/,tests/fixtures/payzen/ - Tests fonctionnels :
tests/Controller/HelloAssoCallbackControllerTest.php - Mock d'
HttpClientInterfacepour stub HelloAsso API calls dansHelloAssoPaymentService - Pour PayZen, mocker
Payum::getHttpRequestVerifier()etNotify
Observabilité
Channels Monolog
| Channel | Contenu | Fichier |
|---|---|---|
helloasso | Toutes les étapes du webhook HelloAsso (réception, auth, idempotence, création Flux) | var/log/helloasso-{env}.log |
payum | Cycles Payum (capture, notify, statuts) | var/log/payum-{env}.log |
security | Rejets webhook (token mismatch, payload forgé) | var/log/security-{env}.log |
Métriques à surveiller
-
Taux de succès : ratio réponses
2xx/ total webhooks reçus. Cible > 99,5 % sur 7 jours glissants. - Latence p95 : temps de traitement du webhook. Cible < 1 s, seuil d'alerte > 3 s (HelloAsso retentera à 5 s).
-
Taux de retries : nombre de webhooks rejoués avec un
helloassoiddéjà connu. Une hausse peut signaler une latence trop élevée côté Kohinos. -
Rejets d'auth : compteur d'événements
warningtoken mismatch — une hausse soudaine = scan en cours (mettre en quarantaine). -
Divergence Order/Flux : requête nocturne comparant le nombre
d'
HelloAssoauxFluxrattachés. Toute différence non expliquée doit être investiguée.
Requête d'audit type
-- HelloAsso orders reçus mais sans Flux associé (réconciliation)
SELECT h.helloassoid, h.created_at, h.email
FROM helloasso h
LEFT JOIN flux f ON f.helloasso_id = h.id
WHERE f.id IS NULL
AND h.created_at > NOW() - INTERVAL 7 DAY
ORDER BY h.created_at DESC;
Pour aller plus loin
Sources
src/Controller/HelloAssoCallbackController.phpsrc/Service/HelloAssoWebhookAuthenticator.phpsrc/Service/HelloAssoPaymentService.phpsrc/Service/HelloAssoFormFilter.phpsrc/Controller/FluxController.php(méthodespreparePaymentActionetdoneAction)src/Entity/HelloAsso.php,src/Entity/PaymentToken.phpdocs/helloasso/INTEGRATION_HELLOASSO_KOHINOS.md- Invariants financiers · Cryptographie & chaînage