Version de travail · contenus et exemples à affiner Voir les 95 pages ↗
Parlons de votre projet ↗
Webhooks / P043

Exemples de code Webhooks

Adaptation éditoriale
Référence de travail. Les routes, schémas, limites et procédures hérités du référentiel sont à aligner avec l’API d’Éric. Ils ne prouvent pas une fonctionnalité livrée. Les hôtes .invalid sont des exemples inertes.

Cette page contient des exemples complets d'implémentation de webhooks Kelyko dans différents langages. Tous les exemples incluent :

  • Vérification de la signature HMAC-SHA256
  • Brancher sur l'en-tête Kelyko-Event. Les corps sont dans Événements
  • Traitement des événements
  • Gestion d'erreurs

Serveur webhook

Le même serveur, dans quatre langages. Choisissez votre onglet.

Exemple · Node.js

const express = require('express');
const crypto = require('crypto');
const app = express();

// Middleware pour parser le body brut (important pour la vérification de signature)
app.use('/webhook', express.raw({ type: 'application/json' }));

const WEBHOOK_SECRET = process.env.Kelyko_WEBHOOK_SECRET;

function verifySignature(rawBody, signature) {
  const expectedSignature = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(rawBody)
    .digest('hex');

  const received = Buffer.from(signature);
  const expected = Buffer.from(expectedSignature);

  // timingSafeEqual lève une exception si les longueurs diffèrent, ce qui est
  // justement le cas d'une signature falsifiée : comparer les tailles d'abord.
  if (received.length !== expected.length) return false;

  return crypto.timingSafeEqual(received, expected);
}

app.post('/webhook', async (req, res) => {
  try {
    // Vérifier la signature
    const signature = req.headers['Kelyko-signature'];
    if (!signature || !verifySignature(req.body, signature)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // TRANSACTION_COMPLETED est plat. SERVICE_PROVIDER_LINK_REQUEST est aussi
    // un corps plat : l'événement est l'en-tête Kelyko-Event. HMAC porte sur le body brut.
    const body = JSON.parse(req.body.toString());
    const event = req.headers['Kelyko-event'];

    // Accuser réception avant de traiter
    res.status(200).json({ received: true });

    if (event === 'SERVICE_PROVIDER_LINK_REQUEST') {
      await handleLinkRequest(body);
    } else if (body.event) {
      await handleEscrow(body);
    } else {
      await handleTransaction(body);
    }
  } catch (error) {
    console.error('Webhook error:', error);
    // Le 200 est déjà parti avant le traitement : répondre à nouveau lèverait
    // ERR_HTTP_HEADERS_SENT. On journalise, et on rejoue de son côté.
    if (!res.headersSent) {
      res.status(500).json({ error: 'Internal server error' });
    }
  }
});

async function handleTransaction(transaction) {
  console.log('Transaction:', transaction.id);
  console.log('Type:', transaction.transactionType);
  console.log('Status:', transaction.status); // "pending", "success" ou "error"
  console.log('Amount:', transaction.amount);
  console.log('Fees:', transaction.fees);
  console.log('Store:', transaction.storeName);
  console.log('Business:', transaction.businessName);
  console.log('Executed at:', transaction.executedAt); // "2024-01-15 14:30:25"

  // Détails de la transaction (champs optionnels)
  const details = transaction.transactionDetails ?? {};
  if (details.id) console.log('Payment request ID:', details.id);
  if (details.reference) console.log('Reference:', details.reference);
  if (details.paymentLinkId) console.log('Payment Link ID:', details.paymentLinkId);

  if (transaction.status === 'success') {
    console.log('Transaction réussie:', transaction.id);
    console.log('Contrepartie:', transaction.counterpartLabel);
    console.log('Identifiant:', transaction.counterpartIdentifier);
    // Mettre à jour votre base de données, envoyer une confirmation, etc.
  } else if (transaction.status === 'error') {
    console.log('Transaction échouée:', transaction.id);
    // Gérer l'échec, notifier l'utilisateur, etc.
  }
}

async function handleLinkRequest(payload) {
  console.log('Link request:', payload.id, payload.status);
}

async function handleEscrow(body) {
  console.log('Escrow:', body.event, body.escrowId, body.status);
}

app.listen(3000, () => {
  console.log('Webhook server listening on port 3000');
});

Exemple · PHP

<?php
$webhookSecret = getenv('Kelyko_WEBHOOK_SECRET');

// Récupérer le body brut
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_Kelyko_SIGNATURE'] ?? '';

// Vérifier la signature
$expectedSignature = hash_hmac('sha256', $rawBody, $webhookSecret);
if (!hash_equals($expectedSignature, $signature)) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid signature']);
    exit;
}

// TRANSACTION_COMPLETED est plat. SERVICE_PROVIDER_LINK_REQUEST est aussi
// un corps plat : l'événement est l'en-tête Kelyko-Event. HMAC porte sur le body brut.
$body = json_decode($rawBody, true);
$event = $_SERVER['HTTP_Kelyko_EVENT'] ?? '';

// Accuser réception
http_response_code(200);
echo json_encode(['received' => true]);

if ($event === 'SERVICE_PROVIDER_LINK_REQUEST') {
    handleLinkRequest($body);
} elseif (!empty($body['event'])) {
    handleEscrow($body);
} else {
    handleTransaction($body);
}

function handleLinkRequest($payload) {
    error_log('Link request: ' . $payload['id'] . ' ' . $payload['status']);
}

function handleEscrow($body) {
    error_log('Escrow: ' . $body['event'] . ' ' . $body['escrowId'] . ' ' . $body['status']);
}

function handleTransaction($transaction) {
    error_log('Transaction: ' . $transaction['id']);
    error_log('Type: ' . $transaction['transactionType']);
    error_log('Status: ' . $transaction['status']); // "pending", "success" ou "error"
    error_log('Amount: ' . $transaction['amount']['amount']);
    error_log('Fees: ' . $transaction['fees']['amount']);
    error_log('Store: ' . $transaction['storeName']);
    error_log('Business: ' . $transaction['businessName']);
    error_log('Executed at: ' . $transaction['executedAt']); // "2024-01-15 14:30:25"

    // Détails de la transaction (champs optionnels)
    $details = $transaction['transactionDetails'] ?? [];
    if (isset($details['id'])) {
        error_log('Payment request ID: ' . $details['id']);
    }
    if (isset($details['reference'])) {
        error_log('Reference: ' . $details['reference']);
    }
    if (isset($details['paymentLinkId'])) {
        error_log('Payment Link ID: ' . $details['paymentLinkId']);
    }

    if ($transaction['status'] === 'success') {
        error_log('Transaction réussie: ' . $transaction['id']);
        error_log('Contrepartie: ' . $transaction['counterpartLabel']);
        error_log('Identifiant: ' . $transaction['counterpartIdentifier']);
        // Mettre à jour votre base de données, envoyer une confirmation, etc.
    } else if ($transaction['status'] === 'error') {
        error_log('Transaction échouée: ' . $transaction['id']);
        // Gérer l'échec, notifier l'utilisateur, etc.
    }
}
?>

Exemple · Python

from flask import Flask, request, jsonify
import hmac
import hashlib
import json
import os

app = Flask(__name__)
WEBHOOK_SECRET = os.getenv('Kelyko_WEBHOOK_SECRET')

def verify_signature(raw_body, signature):
    expected_signature = hmac.new(
        WEBHOOK_SECRET.encode('utf-8'),
        raw_body,
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected_signature, signature)

@app.route('/webhook', methods=['POST'])
def webhook():
    try:
        # Récupérer le body brut
        raw_body = request.get_data()
        signature = request.headers.get('Kelyko-Signature', '')

        # Vérifier la signature
        if not verify_signature(raw_body, signature):
            return jsonify({'error': 'Invalid signature'}), 401

        # TRANSACTION_COMPLETED est plat. SERVICE_PROVIDER_LINK_REQUEST est aussi
        # un corps plat : l'événement est l'en-tête Kelyko-Event. HMAC porte sur le body brut.
        body = json.loads(raw_body)
        event = request.headers.get('Kelyko-Event', '')

        if event == 'SERVICE_PROVIDER_LINK_REQUEST':
            handle_link_request(body)
        elif body.get('event'):
            handle_escrow(body)
        else:
            handle_transaction(body)

        return jsonify({'received': True}), 200
    except Exception as e:
        print(f'Webhook error: {e}')
        return jsonify({'error': 'Internal server error'}), 500

def handle_transaction(transaction):
    print(f"Transaction: {transaction['id']}")
    print(f"Type: {transaction['transactionType']}")
    print(f"Status: {transaction['status']}")  # "pending", "success" ou "error"
    print(f"Amount: {transaction['amount']['amount']}")
    print(f"Fees: {transaction['fees']['amount']}")
    print(f"Store: {transaction['storeName']}")
    print(f"Business: {transaction['businessName']}")
    print(f"Executed at: {transaction['executedAt']}")  # "2024-01-15 14:30:25"

    # Détails de la transaction (champs optionnels)
    details = transaction.get('transactionDetails') or {}
    if 'id' in details:
        print(f"Payment request ID: {details['id']}")
    if 'reference' in details:
        print(f"Reference: {details['reference']}")
    if 'paymentLinkId' in details:
        print(f"Payment Link ID: {details['paymentLinkId']}")

    if transaction['status'] == 'success':
        print(f"Transaction réussie: {transaction['id']}")
        print(f"Contrepartie: {transaction['counterpartLabel']}")
        print(f"Identifiant: {transaction['counterpartIdentifier']}")
        # Mettre à jour votre base de données, envoyer une confirmation, etc.
    elif transaction['status'] == 'error':
        print(f"Transaction échouée: {transaction['id']}")
        # Gérer l'échec, notifier l'utilisateur, etc.

def handle_link_request(payload):
    print(f"Link request: {payload['id']} {payload['status']}")

def handle_escrow(body):
    print(f"Escrow: {body['event']} {body['escrowId']} {body['status']}")

if __name__ == '__main__':
    app.run(port=3000)

Exemple · Ruby

require 'sinatra'
require 'json'
require 'openssl'

WEBHOOK_SECRET = ENV['Kelyko_WEBHOOK_SECRET']

def verify_signature(raw_body, signature)
  expected_signature = OpenSSL::HMAC.hexdigest(
    OpenSSL::Digest.new('sha256'),
    WEBHOOK_SECRET,
    raw_body
  )

  # Comparaison sécurisée pour éviter les attaques par timing
  return false if expected_signature.length != signature.length

  result = 0
  expected_signature.bytes.zip(signature.bytes) do |x, y|
    result |= x ^ y
  end
  result == 0
rescue
  false
end

post '/webhook' do
  begin
    # Récupérer le body brut
    raw_body = request.body.read
    signature = request.env['HTTP_Kelyko_SIGNATURE'] || ''

    # Vérifier la signature
    unless verify_signature(raw_body, signature)
      status 401
      return { error: 'Invalid signature' }.to_json
    end

    # TRANSACTION_COMPLETED est plat. SERVICE_PROVIDER_LINK_REQUEST est aussi
    # un corps plat : l'événement est l'en-tête Kelyko-Event. HMAC porte sur le body brut.
    body = JSON.parse(raw_body)
    event = request.env['HTTP_Kelyko_EVENT'] || ''

    if event == 'SERVICE_PROVIDER_LINK_REQUEST'
      handle_link_request(body)
    elsif body['event']
      handle_escrow(body)
    else
      handle_transaction(body)
    end

    status 200
    { received: true }.to_json
  rescue => e
    puts "Webhook error: #{e.message}"
    status 500
    { error: 'Internal server error' }.to_json
  end
end

def handle_transaction(transaction)
  puts "Transaction: #{transaction['id']}"
  puts "Type: #{transaction['transactionType']}"
  puts "Status: #{transaction['status']}"  # "pending", "success" ou "error"
  puts "Amount: #{transaction['amount']['amount']}"
  puts "Fees: #{transaction['fees']['amount']}"
  puts "Store: #{transaction['storeName']}"
  puts "Business: #{transaction['businessName']}"
  puts "Executed at: #{transaction['executedAt']}"  # "2024-01-15 14:30:25"

  # Détails de la transaction (champs optionnels)
  details = transaction['transactionDetails'] || {}
  puts "Payment request ID: #{details['id']}" if details['id']
  puts "Reference: #{details['reference']}" if details['reference']
  puts "Payment Link ID: #{details['paymentLinkId']}" if details['paymentLinkId']

  if transaction['status'] == 'success'
    puts "Transaction réussie: #{transaction['id']}"
    puts "Contrepartie: #{transaction['counterpartLabel']}"
    puts "Identifiant: #{transaction['counterpartIdentifier']}"
    # Mettre à jour votre base de données, envoyer une confirmation, etc.
  elsif transaction['status'] == 'error'
    puts "Transaction échouée: #{transaction['id']}"
    # Gérer l'échec, notifier l'utilisateur, etc.
  end
end

def handle_link_request(payload)
  puts "Link request: #{payload['id']} #{payload['status']}"
end

def handle_escrow(body)
  puts "Escrow: #{body['event']} #{body['escrowId']} #{body['status']}"
end

Test avec cURL

# Exemple de test local avec ngrok
# 1. Démarrer votre serveur local
# 2. Exposer avec ngrok: ngrok http 3000
# 3. Configurer l'URL ngrok dans le portail marchand

# Test manuel du webhook
curl -X POST http://localhost:3000/webhook \
  -H "Content-Type: application/json" \
  -H "Kelyko-Signature: your_test_signature" \
  -d '{
    "id": "txn_test123",
    "amount": {
      "amount": 10000,
      "currency": "XOF"
    },
    "fees": {
      "amount": 100,
      "currency": "XOF"
    },
    "status": "success",
    "counterpartLabel": "John Doe",
    "counterpartIdentifier": "+2250701234567",
    "paymentMethod": "wave",
    "transactionType": "payment",
    "businessName": "Ma Boutique",
    "storeName": "Magasin Principal",
    "description": "Test payment",
    "executedAt": "2024-01-15 14:30:25",
    "transactionDetails": {
      "id": "d22c81f3-ee04-4ec5-8bd2-cd8af5dabcfc",
      "reference": "TEST-001",
      "paymentLinkId": "abc123def456"
    }
  }'

Cinq erreurs à ne pas commettre

  1. Body brut : Utilisez toujours le body brut (raw body) pour calculer la signature, pas le JSON parsé
  2. Comparaison sécurisée : Utilisez une comparaison sécurisée (timing-safe) pour éviter les attaques par timing
  3. Réponse rapide : Accusez réception sans attendre la fin du traitement. Au-delà de 30 secondes, le webhook est réessayé
  4. Traitement asynchrone : Pour les traitements longs, acceptez le webhook immédiatement et traitez-le en arrière-plan
  5. Idempotence : Assurez-vous que le traitement est idempotent pour éviter les doublons