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_...etpk_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.
- Tracking livraison : statuts de commande et notifications
- SEO local pour boutique e-commerce : Dakar et Abidjan
Ressources et références
- Documentation Medusa Payment Provider : docs.medusajs.com
- Référence Medusa Payment Module : commerce-modules/payment
- Releases Medusa : github.com/medusajs/medusa/releases (version 2.15.2)
- Paystack Initialize Transaction : paystack.com/docs/api/transaction
- Paystack Verify Payments : paystack.com/docs/payments/verify-payments
- Stripe Global availability : stripe.com/global
- Comparatif e-commerce : Shopify, WooCommerce et Medusa pour PME en Afrique
- Comparatif PSP : Stripe, Paystack, Flutterwave et Wave
- Déploiement headless sur Hetzner : Strapi, Directus, Payload sur Hetzner
À 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.