Intégrer MonCash avec FastAPI
Une API FastAPI complète pour MonCash : endpoint async de création, page de retour, webhook signé HMAC-SHA256 lu sur le body brut et traitement en BackgroundTasks.
Commencez en Sandbox
Avant d'implémenter le code ci-dessous avec votre clé live (sk_proj_…), testez d'abord en mode Sandbox avec sk_test_proj_…. Le code est identique — vous ne changez que la valeur de la variable d'environnement. Lisez le Guide Sandbox avant de continuer.
Prérequis
- Python 3.10+ (3.11 recommandé)
- FastAPI 0.110+ et uvicorn
- Un projet MonCashConnect avec
sk_proj_…etwhsec_…— créer un projet
Installation
pip install fastapi uvicorn moncashconnect pydantic-settings python-dotenvLe SDK moncashconnect est pure-Python sans dépendance (utilise la stdlib pour HTTP et HMAC).
Variables d'environnement
Fichier .env (non versionné) :
MCC_SECRET_KEY=sk_proj_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
MCC_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
APP_URL=https://api.votresite.comSettings Pydantic — app/config.py :
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
mcc_secret_key: str
mcc_webhook_secret: str
app_url: str = "http://localhost:8000"
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
settings = Settings() # noqa: pyrightClient MonCash partagé
Instanciez le client une seule fois au démarrage, dans app/mcc.py :
from moncashconnect import MonCashClient
from app.config import settings
# Instancié une fois — pas de state mutable, safe à partager
client = MonCashClient(settings.mcc_secret_key)Créer un paiement (POST /pay)
Dans app/main.py :
import asyncio
from fastapi import FastAPI, HTTPException, Request, BackgroundTasks
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from moncashconnect import MonCashError, construct_event
from app.config import settings
from app.mcc import client
app = FastAPI(title="Boutique MonCash")
class CreatePaymentIn(BaseModel):
order_id: str = Field(..., min_length=1, max_length=64)
amount: float = Field(..., gt=0)
customer_name: str | None = None
customer_email: str | None = None
class CreatePaymentOut(BaseModel):
payment_url: str
reference: str
@app.post("/pay", response_model=CreatePaymentOut)
async def create_payment(body: CreatePaymentIn) -> CreatePaymentOut:
"""Crée un paiement MonCash et renvoie l'URL de redirection."""
try:
# Le SDK est sync — on l'exécute dans un thread pour ne pas
# bloquer la boucle d'événements asyncio.
payment = await asyncio.to_thread(
client.create_payment,
amount=body.amount,
reference_id=body.order_id,
return_url=f"{settings.app_url}/pay/return",
customer_name=body.customer_name,
customer_email=body.customer_email,
)
except MonCashError as exc:
# 409 si referenceId déjà utilisé, 400 si montant invalide, etc.
raise HTTPException(status_code=exc.status_code, detail=str(exc))
return CreatePaymentOut(
payment_url=payment["paymentUrl"],
reference=payment["reference"],
)Page de retour (GET /pay/return)
from fastapi import Query
@app.get("/pay/return")
async def payment_return(ref: str = Query(..., description="reference MonCash")):
"""Vérifie le statut côté serveur — ne pas se fier aux params URL seuls."""
try:
tx = await asyncio.to_thread(client.get_payment_status, ref)
except MonCashError as exc:
raise HTTPException(status_code=exc.status_code, detail=str(exc))
return {
"status": tx["status"], # 'pending' | 'completed' | 'failed'
"amount": tx.get("amount"),
"reference": tx.get("reference"),
}pending. Ne marquez la commande comme payée qu'à réception du webhook payment.completed.Webhook avec raw body + BackgroundTasks
C'est l'endpoint critique : on lit les bytes du body avant tout parsing, on vérifie la signature, puis on traite l'événement en tâche de fond pour répondre 200 immédiatement.
import json
from fastapi import Header
from fastapi.responses import PlainTextResponse
def _process_event(event: dict) -> None:
"""Logique métier — appelée en BackgroundTasks."""
if event["event"] == "payment.completed":
# mark_order_paid(reference=event["reference"], amount=event["amount"])
pass
elif event["event"] == "payment.failed":
# mark_order_failed(reference=event["reference"])
pass
# Tout autre type d'événement : on log et on ignore
@app.post("/webhooks/moncash", response_class=PlainTextResponse)
async def moncash_webhook(
request: Request,
background_tasks: BackgroundTasks,
x_mcc_signature: str = Header(default=""),
x_mcc_timestamp: str = Header(default=""),
):
# 1) Lire les bytes BRUTS — pas request.json() ni request.form()
raw_body = await request.body()
# 2) Vérifier la signature HMAC-SHA256
try:
event = construct_event(
raw_body,
x_mcc_signature,
x_mcc_timestamp,
settings.mcc_webhook_secret,
)
except MonCashError as exc:
# 401 si signature invalide, 400 si timestamp trop vieux
return PlainTextResponse(str(exc), status_code=exc.status_code)
# 3) Renvoyer 200 le plus vite possible — traitement en arrière-plan
background_tasks.add_task(_process_event, event)
return "OK"hmac.new(secret, f"{timestamp}.{raw_body.decode()}".encode(), "sha256").hexdigest() et comparez avec hmac.compare_digest.await request.json() avant la vérification HMAC. FastAPI buffer/reparse le body et la signature ne correspondra plus.Liste de contrôle avant production
MCC_SECRET_KEY et MCC_WEBHOOK_SECRET sont chargés depuis variables d'environnement (pas hardcodés)
Le SDK est appelé via asyncio.to_thread() pour ne pas bloquer la boucle async
request.body() est lu AVANT tout parsing JSON dans le webhook
L'endpoint webhook est idempotent (vérifie l'état avant de créditer)
BackgroundTasks renvoie 200 immédiatement, le traitement métier suit
uvicorn est lancé derrière un reverse proxy (Caddy / nginx) qui termine le TLS
L'URL https://api.votresite.com/webhooks/moncash est enregistrée dans MonCashConnect
Les clés Sandbox sk_test_proj_… sont remplacées par sk_proj_… en production
Lancement de production :
uvicorn app.main:app \
--host 0.0.0.0 \
--port 8000 \
--workers 2 \
--proxy-headers \
--forwarded-allow-ips '*'FAQ
Pourquoi lire request.body() et pas request.json() dans le webhook ?+
Le SDK moncashconnect est-il async ?+
Pourquoi traiter le webhook en BackgroundTasks plutôt qu'en ligne ?+
Faut-il un compte marchand Digicel séparé ?+
Comment tester localement avec uvicorn et ngrok ?+
Pydantic v1 ou v2 ?+
Lectures recommandées :