Webhooks

Recevez une notification HTTP POST à chaque changement de statut d'une transaction. Le moyen le plus fiable de savoir qu'un paiement a réussi.

Principe

  1. Votre client paie via un fournisseur (CinetPay, PayPal...).
  2. Le fournisseur notifie GerminoPay.
  3. GerminoPay met à jour le statut et crédite votre solde.
  4. GerminoPay envoie un POST à votre URL webhook avec les détails.
  5. Votre serveur répond en HTTP 200.

Configuration

Configurez votre URL webhook depuis votre tableau de bord marchand :

👉 Tableau de bord → Clés API → URL Webhook

HTTPS obligatoire en production Votre URL webhook doit être en HTTPS. Les URL HTTP sont rejetées pour protéger les données clients.

Payload

POST → votre URL
{
  "event": "payment.succeeded",
  "created_at": "2025-10-01T13:32:15Z",
  "data": {
    "reference": "GP-20251001-ABC123",
    "transaction_id": "txn_9f8e7d6c5b4a",
    "fournisseur": "cinetpay",
    "montant": 5000,
    "devise": "XOF",
    "frais": 75,
    "montant_net": 4925,
    "statut": "succes",
    "client_email": "client@example.com",
    "metadata": { "commande_id": 1042 },
    "paid_at": "2025-10-01T13:32:15Z"
  }
}

Événements

Event Déclenché quand
payment.initiatedUn paiement vient d'être créé.
payment.processingLe fournisseur traite le paiement.
payment.succeededPaiement confirmé. Solde crédité.
payment.failedPaiement échoué.
payment.refundedRemboursement effectué.
payout.processedUn retrait a été envoyé.
payout.rejectedUn retrait a été rejeté.

Signature

Chaque webhook est signé avec votre clé secrète pour vérifier l'authenticité. La signature est dans l'en-tête :

HTTP Headers
POST /webhook/germinopay HTTP/1.1
Host: monsite.com
Content-Type: application/json
X-GerminoPay-Signature: sha256=9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c
X-GerminoPay-Timestamp: 1730000000

Pour vérifier, calculez :

Pseudo-code
expected = "sha256=" + HMAC_SHA256(
    key = secret_key,
    message = timestamp + "." + raw_body
)

valid = hmac.compare(expected, header_signature)

Exemple de handler

<?php
// webhook.php

$secret = getenv("GERMINOPAY_SECRET");
$rawBody = file_get_contents("php://input");
$timestamp = $_SERVER["HTTP_X_GERMINOPAY_TIMESTAMP"] ?? "";
$signature = $_SERVER["HTTP_X_GERMINOPAY_SIGNATURE"] ?? "";

// 1. Vérifier la signature
$expected = "sha256=" . hash_hmac("sha256", $timestamp . "." . $rawBody, $secret);

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit("Invalid signature");
}

// 2. Protection anti-replay (5 minutes max)
if (abs(time() - (int)$timestamp) > 300) {
    http_response_code(400);
    exit("Timestamp expired");
}

// 3. Traiter l'événement
$payload = json_decode($rawBody, true);
$event = $payload["event"];
$data  = $payload["data"];

switch ($event) {
    case "payment.succeeded":
        // Marquer la commande comme payée
        $pdo->prepare("UPDATE commandes SET statut = ? WHERE reference = ?")
            ->execute(["payee", $data["reference"]]);
        break;

    case "payment.failed":
        // Libérer la commande
        break;

    case "payment.refunded":
        // Journaliser le remboursement
        break;
}

// 4. Répondre 200 pour acquitter
http_response_code(200);
echo json_encode(["received" => true]);
const crypto = require('crypto');
const express = require('express');
const app = express();

app.post('/webhook/germinopay', express.raw({ type: 'application/json' }), (req, res) => {
  const secret = process.env.GERMINOPAY_SECRET;
  const rawBody = req.body.toString();
  const timestamp = req.header('X-GerminoPay-Timestamp');
  const signature = req.header('X-GerminoPay-Signature');

  // 1. Vérifier la signature
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
    return res.status(401).send('Invalid signature');
  }

  // 2. Anti-replay
  if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
    return res.status(400).send('Timestamp expired');
  }

  // 3. Traiter
  const payload = JSON.parse(rawBody);
  if (payload.event === 'payment.succeeded') {
    // Marquer la commande comme payée
  }

  res.json({ received: true });
});

app.listen(3000);
from flask import Flask, request, abort
import hmac, hashlib, json, os, time

app = Flask(__name__)

@app.route("/webhook/germinopay", methods=["POST"])
def webhook():
    secret = os.environ["GERMINOPAY_SECRET"]
    raw_body = request.get_data(as_text=True)
    timestamp = request.headers.get("X-GerminoPay-Timestamp", "")
    signature = request.headers.get("X-GerminoPay-Signature", "")

    # 1. Vérifier
    message = f"{timestamp}.{raw_body}"
    expected = "sha256=" + hmac.new(
        secret.encode(), message.encode(), hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, signature):
        abort(401)

    # 2. Anti-replay
    if abs(time.time() - int(timestamp)) > 300:
        abort(400)

    # 3. Traiter
    payload = json.loads(raw_body)
    if payload["event"] == "payment.succeeded":
        # Marquer la commande comme payée
        pass

    return {"received": True}, 200

if __name__ == "__main__":
    app.run(port=3000)
require 'sinatra'
require 'openssl'
require 'json'

post '/webhook/germinopay' do
  secret = ENV['GERMINOPAY_SECRET']
  raw_body = request.body.read
  timestamp = request.env['HTTP_X_GERMINOPAY_TIMESTAMP']
  signature = request.env['HTTP_X_GERMINOPAY_SIGNATURE']

  # 1. Vérifier
  expected = 'sha256=' + OpenSSL::HMAC.hexdigest(
    'SHA256', secret, "#{timestamp}.#{raw_body}"
  )

  halt 401, 'Invalid signature' unless Rack::Utils.secure_compare(expected, signature)

  # 2. Anti-replay
  halt 400, 'Timestamp expired' if (Time.now.to_i - timestamp.to_i).abs > 300

  # 3. Traiter
  payload = JSON.parse(raw_body)
  case payload['event']
  when 'payment.succeeded'
    # Marquer la commande
  end

  status 200
  { received: true }.to_json
end

Retry

Si votre serveur ne répond pas avec HTTP 200 dans les 10 secondes, nous retentons avec un backoff exponentiel :

Idempotence Comme nous pouvons retenter, votre handler doit être idempotent. Utilisez la référence pour détecter les doublons.

Bonnes pratiques