Webhooks

Receive an HTTP POST notification whenever a transaction status changes. The most reliable way to know a payment succeeded.

Overview

  1. Your customer pays via a provider (CinetPay, PayPal...).
  2. The provider notifies GerminoPay.
  3. GerminoPay updates the transaction status and credits your balance.
  4. GerminoPay sends a POST to your webhook URL with the details.
  5. Your server acknowledges with HTTP 200.

Configuration

Set your webhook URL from your merchant dashboard:

👉 Dashboard → API keys → Webhook URL

HTTPS required in production Your webhook URL must be HTTPS. HTTP URLs are rejected to protect customer data.

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"
  }
}

Events

Event Triggered when
payment.initiatedA payment was just created.
payment.processingProvider is processing.
payment.succeededPayment is confirmed. Balance credited.
payment.failedPayment failed.
payment.refundedRefund completed.
payout.processedA payout was sent.
payout.rejectedA payout was rejected.

Signature

Every webhook is signed with your secret key so you can verify authenticity. The signature is in the header:

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

To verify, compute:

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

valid = hmac.compare(expected, header_signature)

Handler example

<?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

If your server doesn't respond with HTTP 200 within 10 seconds, we retry with exponential backoff:

Idempotency Because we may retry, your handler must be idempotent. Use the reference to detect duplicates.

Best practices