E-commerce

Medusa.js et Paystack en Afrique : payment provider sur mesure pour la zone Stripe étendue

16 دقائق للقراءة

Quand on choisit Medusa pour un projet e-commerce en Afrique de l’Ouest, on découvre vite que la passerelle de paiement par défaut documentée dans l’écosystème est Stripe. Le problème : Stripe n’opère pas en direct au Sénégal, en Côte d’Ivoire, au Mali, au Burkina, au Togo, au Bénin ni au Niger. La page global availability de Stripe liste, pour le continent, quatre pays via son réseau étendu — Nigeria, Ghana, Afrique du Sud, Kenya — et ce réseau s’appuie en pratique sur Paystack, racheté par Stripe en 2020. La voie réaliste pour accepter des paiements depuis une boutique Medusa en zone CFA passe donc par Paystack, qui couvre nativement le Nigeria, le Ghana, le Kenya, la Côte d’Ivoire et l’Afrique du Sud, et qui propose à la fois cartes bancaires et mobile money selon le pays.

Ce tutoriel pose pas-à-pas la création d’un payment provider Paystack dans Medusa v2, le module de paiement le plus moderne du framework au moment où ces lignes sont écrites. La version stable utilisée est Medusa 2.15.2, publiée le 13 mai, qui requiert Node.js 20 LTS minimum et PostgreSQL. Le provider sera réutilisable, testable en sandbox, et déployable sur un VPS Hetzner.

Pré-requis

  • Node.js 20 LTS ou supérieur (Node 22 ou 24 LTS recommandé ; éviter Node 25+ qui n’est pas supporté par le starter storefront)
  • PostgreSQL 14 ou supérieur
  • Git
  • Un compte marchand Paystack en sandbox (clé sk_test_... et pk_test_... disponibles dans le dashboard)
  • Un éditeur TypeScript (VS Code recommandé)
  • Un domaine ou un tunnel public (ngrok, Cloudflare Tunnel) pour recevoir les webhooks Paystack en local

Étape 1 — Créer le projet Medusa

Medusa propose un installeur officiel create-medusa-app qui scaffolde un backend complet, une base de données et le storefront Next.js si vous le souhaitez. Lancez la commande dans un dossier vierge.

npx create-medusa-app@latest ma-boutique
cd ma-boutique
# Suivre le prompt : nom DB, créer admin user, etc.
npm run dev

L’installeur configure automatiquement la connexion PostgreSQL, applique les migrations initiales et lance le serveur sur http://localhost:9000 (admin sur /app). Le test concluant : vous accédez à l’admin Medusa, vous créez un produit, vous voyez sa page dans le storefront sur http://localhost:8000. Notez le mot de passe admin choisi pendant l’install.

Étape 2 — Architecture d’un module payment provider

Dans Medusa v2, les fournisseurs de paiement sont des modules à part entière. Vous créez un dossier sous src/modules/paystack qui contient au minimum un fichier service.ts exportant la classe provider, et un index.ts qui définit l’export du module. Medusa découvre automatiquement ces modules dès qu’ils sont enregistrés dans medusa-config.ts.

La classe étend AbstractPaymentProvider importé de @medusajs/framework/utils. Cette abstraction définit dix méthodes que vous pouvez implémenter selon votre flux : initiatePayment, authorizePayment, capturePayment, cancelPayment, refundPayment, retrievePayment, updatePayment, deletePayment, getPaymentStatus, getWebhookActionAndData. Pour un flux Paystack en mode redirection-paiement-confirmation, les quatre essentielles sont initiatePayment, authorizePayment, getPaymentStatus et getWebhookActionAndData.

Étape 3 — Squelette de la classe Paystack provider

Créez l’arborescence et le fichier de service.

mkdir -p src/modules/paystack
touch src/modules/paystack/service.ts src/modules/paystack/index.ts

Posez d’abord le squelette de la classe.

// src/modules/paystack/service.ts
import { AbstractPaymentProvider } from "@medusajs/framework/utils";
import {
  InitiatePaymentInput,
  InitiatePaymentOutput,
  AuthorizePaymentInput,
  AuthorizePaymentOutput,
  GetPaymentStatusInput,
  GetPaymentStatusOutput,
  ProviderWebhookPayload,
  WebhookActionResult,
} from "@medusajs/framework/types";

type Options = {
  secret_key: string;
  public_key: string;
  callback_url: string;
};

class PaystackProviderService extends AbstractPaymentProvider<Options> {
  static identifier = "paystack";
  protected options_: Options;

  constructor(cradle: any, options: Options) {
    super(cradle, options);
    this.options_ = options;
  }

  protected async paystackFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
    const res = await fetch(`https://api.paystack.co${path}`, {
      ...init,
      headers: {
        Authorization: `Bearer ${this.options_.secret_key}`,
        "Content-Type": "application/json",
        ...(init.headers || {}),
      },
    });
    if (!res.ok) {
      const body = await res.text();
      throw new Error(`Paystack API ${res.status}: ${body}`);
    }
    return res.json() as Promise<T>;
  }
}

export default PaystackProviderService;

L’identifiant paystack sera utilisé partout (côté front, côté admin, dans les listes de régions). Le helper paystackFetch centralise l’authentification Bearer et la conversion JSON — utile parce que les dix méthodes du provider appellent toutes l’API Paystack avec le même header. Le test concluant à ce stade : le fichier compile sans erreur TypeScript (npx tsc --noEmit).

Étape 4 — Implémenter initiatePayment

Cette méthode est appelée par Medusa quand le client clique « Payer » dans le checkout. Elle initialise la transaction côté Paystack et retourne l’identifiant de session de paiement plus une URL d’autorisation vers laquelle on redirigera le navigateur. L’endpoint Paystack POST /transaction/initialize attend un email, un montant en sous-unités (multiplié par 100) et un code de devise, et retourne dans son champ data un authorization_url, un access_code et une reference unique.

async initiatePayment(
  input: InitiatePaymentInput
): Promise<InitiatePaymentOutput> {
  const amount = Math.round(Number(input.amount) * 100); // sous-unités
  const email  = (input.context as any)?.customer?.email ?? "noreply@itskillscenter.io";
  const currency = (input.currency_code || "ngn").toUpperCase();

  const res = await this.paystackFetch<{ data: { reference: string; access_code: string; authorization_url: string }}>("/transaction/initialize", {
    method: "POST",
    body: JSON.stringify({
      email,
      amount,
      currency,
      callback_url: this.options_.callback_url,
      metadata: { medusa_session_id: (input.context as any)?.session_id },
    }),
  });

  return {
    id: res.data.reference,
    data: {
      reference: res.data.reference,
      access_code: res.data.access_code,
      authorization_url: res.data.authorization_url,
    },
  };
}

Trois choix de conception méritent commentaire. Premièrement, la conversion en sous-unités est obligatoire pour Paystack : 10 000 NGN se transmet comme 1000000. Deuxièmement, la devise par défaut est NGN parce que c’est la plus stable de Paystack ; pour la Côte d’Ivoire, basculez sur XOF, ou GHS pour le Ghana. Troisièmement, l’authorization_url retournée par Paystack est l’URL hébergée par Paystack vers laquelle le client doit être redirigé pour saisir ses informations de paiement (carte, USSD, mobile money selon le pays). Le test concluant : appeler manuellement medusa store/payment-sessions et vérifier que la réponse contient une authorization_url qui ouvre bien la page Paystack sandbox.

Étape 5 — Implémenter authorizePayment et getPaymentStatus

Une fois le client redirigé sur Paystack, il valide son paiement puis revient sur le callback_url que vous avez configuré. Côté Medusa, c’est le moment d’appeler authorizePayment pour confirmer que le paiement a bien été capturé côté PSP. L’endpoint à utiliser est GET /transaction/verify/{reference}. La réponse contient un champ status qui vaut success, failed, abandoned ou pending.

async authorizePayment(
  input: AuthorizePaymentInput
): Promise<AuthorizePaymentOutput> {
  const reference = String((input.data as any)?.reference ?? "");
  const verify = await this.paystackFetch<{ data: { status: string; amount: number; reference: string }}>(`/transaction/verify/${reference}`);

  const status = verify.data.status === "success" ? "authorized" : "pending";

  return { status, data: { ...(input.data ?? {}), verified: verify.data } };
}

async getPaymentStatus(
  input: GetPaymentStatusInput
): Promise<GetPaymentStatusOutput> {
  const reference = String((input.data as any)?.reference ?? "");
  try {
    const verify = await this.paystackFetch<{ data: { status: string }}>(`/transaction/verify/${reference}`);
    const status = verify.data.status === "success" ? "captured" : verify.data.status;
    return { status, data: input.data };
  } catch {
    return { status: "error", data: input.data };
  }
}

Vérifiez toujours le statut côté Paystack : ne jamais se fier à un simple paramètre d’URL pour valider une transaction. Paystack peut retourner un status à success mais avec un amount qui ne correspond pas — vérifiez aussi le montant pour défendre l’intégrité de la commande.

Étape 6 — Implémenter getWebhookActionAndData

Paystack envoie un webhook signé HMAC SHA-512 à chaque événement notable (paiement réussi, échec, remboursement). Medusa expose un endpoint webhook unifié sur /hooks/payment/paystack qui invoque la méthode getWebhookActionAndData() du provider. Cette méthode décide quelle action déclencher côté Medusa selon l’événement reçu.

async getWebhookActionAndData(payload: ProviderWebhookPayload["payload"]): Promise<WebhookActionResult> {
  const event = payload.data as any;
  const eventType = event?.event ?? "";
  const tx = event?.data;

  if (!tx) return { action: "not_supported" };

  switch (eventType) {
    case "charge.success":
      return {
        action: "captured",
        data: { session_id: tx.metadata?.medusa_session_id, amount: tx.amount / 100 },
      };
    default:
      return { action: "not_supported" };
  }
}

Paystack n’émet aujourd’hui qu’un seul événement de paiement, charge.success ; les transactions qui échouent ne déclenchent pas de webhook. Pour détecter un échec, vous interrogez GET /transaction/verify/{reference} à intervalle régulier sur les sessions en attente. La vérification HMAC du webhook doit se faire en amont, idéalement dans un middleware Medusa qui lit le header x-paystack-signature et le compare à crypto.createHmac("sha512", secret).update(body).digest("hex"). Si la signature ne correspond pas, on rejette la requête en 401 sans même appeler la méthode du provider. La validation : tester manuellement avec un payload Paystack copié depuis le dashboard sandbox et vérifier que la commande Medusa passe à « captured ».

Étape 7 — Déclarer et enregistrer le module

Un module Medusa expose deux fichiers : un index.ts qui définit son nom et son service principal, et une entrée dans medusa-config.ts qui le charge au démarrage avec ses options. Créez d’abord src/modules/paystack/index.ts.

// src/modules/paystack/index.ts
import { ModuleProvider, Modules } from "@medusajs/framework/utils";
import PaystackProviderService from "./service";

export default ModuleProvider(Modules.PAYMENT, {
  services: [PaystackProviderService],
});

Puis ouvrez medusa-config.ts et ajoutez le bloc modules qui enregistre le provider Paystack avec ses options de configuration. Les valeurs viennent des variables d’environnement, jamais en dur dans le code.

// medusa-config.ts
import { defineConfig, Modules } from "@medusajs/framework/utils";

export default defineConfig({
  projectConfig: { databaseUrl: process.env.DATABASE_URL },
  modules: {
    [Modules.PAYMENT]: {
      resolve: "@medusajs/medusa/payment",
      options: {
        providers: [
          {
            resolve: "./src/modules/paystack",
            id: "paystack",
            options: {
              secret_key: process.env.PAYSTACK_SECRET_KEY!,
              public_key: process.env.PAYSTACK_PUBLIC_KEY!,
              callback_url: process.env.PAYSTACK_CALLBACK_URL!,
            },
          },
        ],
      },
    },
  },
});

Créez ensuite un fichier .env à la racine du projet avec les variables correspondantes.

PAYSTACK_SECRET_KEY=sk_test_XXXXXXXXXXXXXXXXXXXXXXXX
PAYSTACK_PUBLIC_KEY=pk_test_XXXXXXXXXXXXXXXXXXXXXXXX
PAYSTACK_CALLBACK_URL=http://localhost:9000/api/store/payment-callbacks/paystack

Relancez npm run dev. Dans l’admin Medusa (Settings > Regions), vous devez maintenant pouvoir associer le provider paystack à une région. La validation : la page admin liste « paystack » comme provider disponible aux côtés de « manual » et de « stripe ».

Étape 8 — Tester en sandbox Paystack

Paystack fournit dans son dashboard un mode test complet avec des cartes de démonstration et des numéros mobile money fictifs. Configurez une région Medusa qui accepte le NGN ou le GHS, associez le provider paystack, créez un produit à 100 NGN, et lancez un parcours d’achat depuis le storefront.

La séquence attendue : le client clique « Payer », le navigateur est redirigé vers https://checkout.paystack.com/..., il saisit une carte test (4084 0840 8408 4081 dans le sandbox), il revient sur votre callback_url, et la commande passe en captured dans Medusa. En parallèle, Paystack envoie un webhook charge.success à votre endpoint /hooks/payment/paystack. Vous voyez la transaction dans le dashboard sandbox de Paystack avec le statut Success.

Si le webhook n’arrive pas en local, il faut publier votre URL avec ngrok ou Cloudflare Tunnel. ngrok http 9000 expose votre Medusa local sur https://abcd-1234.ngrok-free.app ; collez cette URL dans le dashboard Paystack Settings > Webhooks.

Étape 9 — Déployer en production sur Hetzner

Un VPS Hetzner CX22 (2 vCPU, 4 Go de RAM, 4,49 EUR par mois) suffit largement pour faire tourner une boutique Medusa et son storefront pour un trafic de 200 à 2 000 commandes par mois. La méthode la plus simple consiste à utiliser Coolify, qui orchestre l’application, la base PostgreSQL, le serveur Redis et le reverse proxy Caddy avec SSL automatique. Le tutoriel Strapi, Directus, Payload : choisir un headless CMS et le déployer sur Hetzner détaille le déploiement type d’un backend Node.js avec Coolify, intégralement transposable à Medusa.

Trois points spécifiques à un provider de paiement en production. Premièrement, basculez vos clés Paystack en live et activez le KYC marchand côté Paystack pour les retraits effectifs. Deuxièmement, configurez votre URL webhook publique (https://votre-domaine/hooks/payment/paystack) et vérifiez que les IPs Paystack peuvent y accéder (whitelisting sur le firewall si nécessaire). Troisièmement, activez le HTTPS sur le storefront ET le backend : Paystack refuse le mode redirection sans HTTPS sur le callback.

Erreurs fréquentes

Erreur Diagnostic Correction
« Currency not supported » au moment du transaction/initialize Devise non activée dans le compte Paystack Activer NGN, GHS, KES, XOF ou USD dans Settings > Preferences du dashboard Paystack
Montant côté Paystack divisé par 100 Oubli de la conversion sub-units Toujours Math.round(amount * 100) à l’envoi
Webhook reçu mais commande Medusa pas mise à jour Signature HMAC SHA-512 non vérifiée ou metadata medusa_session_id manquante Vérifier l’envoi du metadata dans initiatePayment et la signature dans le middleware
Client revient sur callback mais panier vide Session Medusa expirée avant la redirection Allonger la durée de session, ou récupérer la session depuis la reference Paystack
« 401 Unauthorized » sur appels Paystack Clé inversée (test/live) ou tronquée Vérifier que sk_test_ est bien sandbox et que la clé fait 51 caractères au total
Le provider n’apparaît pas dans Medusa Admin Module non enregistré ou erreur de chargement Lancer medusa develop avec DEBUG=* et lire les logs au démarrage

FAQ

Faut-il utiliser Stripe Connect avec Paystack derrière ? Non, en Afrique de l’Ouest francophone vous n’avez aucun besoin de Stripe Connect. Allez directement chez Paystack pour le Ghana, le Nigeria, la Côte d’Ivoire, l’Afrique du Sud et le Kenya. Stripe Connect est utile uniquement pour le multi-marketplace.

Paystack supporte-t-il le mobile money ? Oui, dans les pays où le rail existe : MoMo Ghana, M-Pesa Kenya, et progressivement la zone CI. La sélection de la méthode de paiement se fait côté Paystack, vous n’avez rien à coder de spécifique côté Medusa.

Peut-on combiner Paystack et un PSP local comme PayDunya dans la même boutique ? Oui. Chaque provider est un module Medusa indépendant. Vous associez à chaque région les providers pertinents : Paystack pour vos régions GH/KE/NG, PayDunya pour vos régions SN/CI/BJ/TG.

Quel storefront pour aller avec ce backend ? Le starter Next.js Starter Storefront de Medusa est mature et bien documenté. Il s’installe avec create-medusa-app en option, et peut se déployer sur Vercel ou Cloudflare Pages pour une excellente latence depuis Dakar et Abidjan.

Combien de temps faut-il prévoir pour la production ? En partant d’un projet vide : 2 à 3 jours pour le provider et l’intégration storefront, 3 à 5 jours pour les fonctionnalités métier (catalogue, promotions, livraison), 1 à 2 jours pour le déploiement et l’observabilité. Comptez 7 à 12 jours homme pour un MVP propre.

Lectures complémentaires

Trois tutoriels prolongent celui-ci sur les axes d’une boutique en ligne complète : choix de plateforme, paiement en zone CFA, suivi opérationnel et acquisition organique.

Hardening production : check-list avant go-live

Le tutoriel ci-dessus décrit le flow nominal et la sécurité de base. Avant la première transaction réelle sur ce code, huit points doivent être verrouillés — chaque omission est documentée comme cause d’incident sur des intégrations en production. La même liste est appliquée par les équipes paiement matures sur les sites en zone CEDEAO.

  1. Secrets jamais en base de données ni en clair en code. Clé API et secret webhook stockés dans un secret manager (HashiCorp Vault, AWS Secrets Manager, Doppler) ou a minima dans le fichier .env hors du repo (avec .gitignore strict) et chmod 600. Vérifier qu’aucune clé prod n’apparaît dans l’historique git via git log -p | grep -i "prod_\|sk_live\|api_key".
  2. Vérification HMAC sur raw body uniquement. Ne jamais re-stringifier le body parsé : les whitespaces, l’ordre des clés JSON et l’encodage UTF-8 doivent rester intacts. Utiliser express.raw() en Node, request.get_data() en Flask avant tout get_json(), file_get_contents("php://input") en PHP (jamais $_POST).
  3. Comparaison signature en temps constant. crypto.timingSafeEqual (Node, vérifier la longueur des buffers avant), hmac.compare_digest (Python), hash_equals (PHP), hmac.Equal (Go). Une comparaison == classique laisse fuir des bits par timing attack.
  4. Idempotence atomique. Contrainte unique en base sur l’ID d’événement provider (event_id Wave, notif_token Orange, X-Reference-Id MTN, id CinetPay/PayDunya/Flutterwave). Pattern INSERT … ON CONFLICT DO NOTHING qui revoie 200 immédiatement sur doublon, sans réappliquer l’effet métier (provisionnement, livraison, email).
  5. Fenêtre anti-replay sur le timestamp. Rejeter tout webhook dont le t= diffère de l’heure serveur de plus de 5 minutes. Évite la replay attack avec une signature historique interceptée. Synchroniser l’heure serveur via NTP (chrony ou systemd-timesyncd) pour éviter les rejets dûs à une dérive d’horloge.
  6. Timeout HTTP explicites séparés. Connect timeout 5 secondes, read timeout 15-30 secondes selon le provider. Jamais d’appel sans timeout — un connect bloqué peut faire monter votre PHP-FPM ou Node worker pool à saturation en quelques secondes.
  7. Retry exponentiel uniquement pour 5xx et 429. Base 2 (1s, 2s, 4s, 8s), plafond 60 secondes, maximum 4 tentatives. Les 4xx (sauf 429) sont des erreurs de configuration qui ne se corrigent pas en rejouant — propager immédiatement à l’opérateur. Utiliser un identifiant de retry stable côté provider (Idempotency-Key Stripe, client_reference Wave, externalId MTN) pour ne pas créer de doublons.
  8. Monitoring + alerting + réconciliation J+1. Métriques Prometheus ou équivalent : taux 401/403/429 sur appels sortants, taux de signatures invalides, latence p95 par provider, échec de réconciliation J-1. Page-out sur seuils stricts. Job cron quotidien 02h00 qui confronte la table interne aux exports providers — trois sorties scénarisées (100 % match, écart minoritaire = rapport finance, écart majoritaire = page-out + suspension nouvelles transactions).

La version exhaustive de cette check-list, avec un exemple de chaque fix en code, est dans le guide Wave Business API en production : KYC, clés live, IP whitelisting et HMAC. Les principes y sont génériques et s’appliquent identiquement à Orange Money, MTN MoMo, Flutterwave, CinetPay, PayDunya et Paystack.

Ressources et références

À combiner avec : mettre en place le paiement Orange Money pour offrir un parcours d’achat sans friction.

Mise en production : avant le go-live, Wave Business API en production : checklist complète.

مشاركة