Skip to content

Guide d'implémentation de réception de Webhook de paiement

Vue d'ensemble

Ce guide explique comment implémenter une API de réception de Webhook de paiement avec NENE2. Fournit la vérification de signature HMAC-SHA256, le traitement idempotent (contrainte UNIQUE event_id) et les gardes de transition d'état.


Schéma DB

sql
CREATE TABLE payments (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    external_id TEXT    NOT NULL UNIQUE,
    amount      INTEGER NOT NULL,               -- unité monétaire minimale (yen, centimes)
    currency    TEXT    NOT NULL DEFAULT 'usd',
    status      TEXT    NOT NULL DEFAULT 'pending'
                        CHECK (status IN ('pending', 'succeeded', 'failed', 'refunded')),
    created_at  TEXT    NOT NULL,
    updated_at  TEXT    NOT NULL
);

CREATE TABLE webhook_events (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    event_id     TEXT    NOT NULL UNIQUE,   -- clé d'idempotence
    event_type   TEXT    NOT NULL,
    payload      TEXT    NOT NULL,          -- JSON
    processed_at TEXT    NOT NULL
);

webhook_events.event_id est au cœur du traitement idempotent. Même si le même event_id est reçu deux fois, il n'est traité qu'une seule fois.


Design des endpoints

MéthodeCheminDescription
POST/webhooks/paymentRéception et traitement des événements Webhook
GET/paymentsListe des paiements
GET/payments/{id}Détail d'un paiement

Transitions d'état

[créé] → pending → succeeded → refunded
                 ↘ failed

Géré avec une table de transitions :

php
private const array VALID_TRANSITIONS = [
    'payment.succeeded' => ['from' => 'pending',   'to' => 'succeeded'],
    'payment.failed'    => ['from' => 'pending',   'to' => 'failed'],
    'payment.refunded'  => ['from' => 'succeeded', 'to' => 'refunded'],
];

Les transitions invalides (failed → succeeded, etc.) retournent 409 Conflict.


Points clés de conception

Vérification de signature HMAC-SHA256

Vérifier l'ensemble du corps de la requête avec HMAC-SHA256. Utiliser l'en-tête X-Webhook-Signature: sha256=<hex> compatible Stripe :

php
private function verifySignature(string $body, string $header): bool
{
    if (!str_starts_with($header, 'sha256=')) {
        return false;
    }
    $provided = substr($header, 7);
    $expected = hash_hmac('sha256', $body, $this->webhookSecret);
    return hash_equals($expected, $provided); // prévention d'attaque de timing
}

Comparer à temps constant avec hash_equals(). === et strcmp() se terminent tôt et sont donc vulnérables.

Traitement idempotent

Les fournisseurs de Webhook réessaient. Dédupliquer avec event_id :

php
// Vérifier avant traitement
if ($this->repo->isEventProcessed($eventId)) {
    return $this->json->create(['status' => 'already_processed']);
}

// Enregistrer après traitement
$this->repo->recordEvent($eventId, $eventType, $payload, $now);

Ordre de traitement

1. Vérification de signature → 401
2. Vérification de duplication de event_id → 200 already_processed
3. Traitement par type d'événement
4. Enregistrement dans webhook_events
5. Retourner 200 processed

Effectuer la vérification de signature en premier pour empêcher les attaquants de contaminer la table event_id.

Retourner 200 pour les types d'événements inconnus

Quand le fournisseur ajoute un nouveau type d'événement, retourner 4xx déclenche des réessais. Retourner silencieusement 200 et enregistrer les types inconnus :

php
// Type d'événement inconnu — acquitter sans traiter
return null; // → 200 processed

Test : générer la signature avec SECRET injecté

php
private const string SECRET = 'test-webhook-secret';

private function signedReq(string $path, array $body): ResponseInterface
{
    $rawBody = json_encode($body);
    $sig     = 'sha256=' . hash_hmac('sha256', $rawBody, self::SECRET);
    // ...
}

Passer le même secret à l'application avec AppFactory::createSqlite($dbFile, self::SECRET).


Exemples de payload d'événement

payment.created

json
{
  "event_id": "evt_001",
  "event_type": "payment.created",
  "data": {"id": "pay_abc", "amount": 5000, "currency": "jpy"}
}

payment.succeeded

json
{
  "event_id": "evt_002",
  "event_type": "payment.succeeded",
  "data": {"id": "pay_abc"}
}

Réponse (succès)

json
{"status": "processed", "event_type": "payment.succeeded"}

Réponse (renvoi idempotent)

json
{"status": "already_processed"}

Implémentation de référence

../NENE2-FT/paymentlog/ — Field Trial FT163 (18 tests, vérification de signature, traitement idempotent, gardes de transition)

Publié sous licence MIT.