documentation
Kohinos v3 Doc générée le commit 8d60b1904

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

AspectHelloAssoPayZen
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
  1. Authentification AVANT tout traitement. Aucune ligne ne touche la base avant que HelloAssoWebhookAuthenticator::isAuthorised() (HelloAsso) ou httpRequestVerifier->verify() + Notify (PayZen) aient validé l'origine. En cas d'échec, répondre 404 (HelloAsso) ou 400 — jamais une page exposant l'existence de l'endpoint.
  2. Idempotence AVANT création du Flux. Pour HelloAsso : $helloAssoRepository->findOneBy(['helloassoid' => $id]) doit retourner null sinon le webhook répond 200 OK sans rejouer l'écriture. Pour PayZen : la combinaison token Payum invalidé + status capturé empêche le rejeu.
  3. Toute création de Flux dans $em->wrapInTransaction() avec LockMode::PESSIMISTIC_WRITE sur les comptes crédités/débités. Référence : SsaRedistributionService (cf. SSA et invariants financiers).
  4. 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.
  5. Jamais de modification de flux.hash aprè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.
  6. Aucun flush() dans un lifecycle Doctrine (postPersist, postUpdate). Tous les événements métier passent par le EventDispatcher Symfony, jamais par les callbacks ORM.

Anti-patterns observés

⛔ À ne pas reproduire
  • Créer le Flux avant 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 reference mé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 sur helloassoid (HelloAsso) ou payum_token (PayZen).
  • Faire le flush() dans un EventSubscriber Doctrine lifecycle. Double-flush ⇒ comportement non-déterministe et risque de race condition entre le webhook et un retry simultané.
  • Répondre 200 OK après une erreur silencieuse. Si la signature est invalide, répondre 401/404. Mais si la signature est valide et que le formulaire HelloAsso n'est pas configuré sur cette instance, répondre 200 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 helloassoid connu avec un montant différent. Considérer comme une tentative de fraude : logger en error, alerter, mais ne pas modifier le Flux existant.

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'HttpClientInterface pour stub HelloAsso API calls dans HelloAssoPaymentService
  • Pour PayZen, mocker Payum::getHttpRequestVerifier() et Notify

Observabilité

Channels Monolog

ChannelContenuFichier
helloassoToutes les étapes du webhook HelloAsso (réception, auth, idempotence, création Flux)var/log/helloasso-{env}.log
payumCycles Payum (capture, notify, statuts)var/log/payum-{env}.log
securityRejets webhook (token mismatch, payload forgé)var/log/security-{env}.log

Métriques à surveiller

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.php
  • src/Service/HelloAssoWebhookAuthenticator.php
  • src/Service/HelloAssoPaymentService.php
  • src/Service/HelloAssoFormFilter.php
  • src/Controller/FluxController.php (méthodes preparePaymentAction et doneAction)
  • src/Entity/HelloAsso.php, src/Entity/PaymentToken.php
  • docs/helloasso/INTEGRATION_HELLOASSO_KOHINOS.md
  • Invariants financiers · Cryptographie & chaînage