Intégrer SenePay : Paiement mobile money pour le marché sénégalais
Dans cet article, je vous explique comment j'ai intégré SenePay pour gérer les paiements mobile money (Wave, Orange Money, Free Money) sur baccalaureat.sn et amarsyll.pro. Une architecture sécurisée avec webhook, idempotence et voter Symfony.
Le contexte marché
Au Sénégal, le paiement par mobile money (Wave, Orange Money, Free Money) est le moyen de paiement dominant. Les cartes bancaires sont très peu utilisées. D'où le choix de SenePay, un agrégateur de paiements mobile money qui couvre 16 pays africains.
Modèle freemium :
- Contenu de base gratuit → moteur du trafic organique
- Fonctionnalités premium à 1 000 FCFA (compilations PDF, quiz illimités)
- Commission SenePay : 3,6 % prélevée au moment du payin
L'architecture du paywall
1. Le flag premium avec expiration
Le premium est stocké comme un flag avec date d'expiration dans l'entité User :
// src/Entity/User.php
#[ORMColumn]
private bool $isPremium = false;
#[ORMColumn(nullable: true)]
private ?DateTimeImmutable $premiumUntil = null;
public function hasPremiumAccess(): bool
{
return $this->isPremium
&& $this->premiumUntil !== null
&& $this->premiumUntil > new DateTimeImmutable();
}
2. Le Voter Symfony pour contrôler l'accès
Un voter Symfony centralise la logique d'autorisation :
// src/Security/Voter/PremiumVoter.php
class PremiumVoter extends Voter
{
public const PREMIUM_ACCESS = 'PREMIUM_ACCESS';
protected function supports(string $attribute, mixed $subject): bool
{
return $attribute === self::PREMIUM_ACCESS;
}
protected function voteOnAttribute(string $attr, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
return $user instanceof User && $user->hasPremiumAccess();
}
}
Utilisation dans les contrôleurs :
$this->denyAccessUnlessGranted(PremiumVoter::PREMIUM_ACCESS);
Le webhook : ne jamais faire confiance au navigateur
Règle d'or : on active le premium uniquement sur webhook serveur-à-serveur, jamais sur le retour navigateur (falsifiable).
Endpoint webhook
// src/Controller/Webhook/SenePayController.php
#[Route('/webhook/senepay', name: 'senepay_webhook', methods: ['POST'])]
public function webhook(Request $request): Response
{
$payload = json_decode($request->getContent(), true);
// 1. Vérification de la signature HMAC-SHA256
if (!$this->senePay->verifySignature($request)) {
return new Response('Invalid signature', 403);
}
// 2. Idempotence : traiter une transaction une seule fois
$reference = $payload['orderReference'] ?? $payload['sessionToken'];
if ($this->paymentRepo->isAlreadyProcessed($reference)) {
return new Response('OK', 200);
}
// 3. Traitement selon l'événement
if ($payload['event'] === 'checkout.session.completed') {
$user = $this->resolveUser($payload['metadata']);
$user->grantPremium(new DateInterval('P30D')); // 30 jours
$this->em->flush();
}
return new Response('OK', 200);
}
Les trois règles d'or des webhooks
- Vérifier la signature : HMAC-SHA256 avec votre `webhookSigningSecret`
- Idempotence : traiter une transaction une seule fois même si le webhook est reçu plusieurs fois
- Source de vérité = le serveur : le retour navigateur, c'est de l'UX, pas une validation
Création d'une session de paiement
// src/Service/SenePayService.php
public function createSession(User $user, int $amount, string $reference): array
{
$response = $this->httpClient->post('https://api.sene-pay.com/api/v1/checkout/sessions', [
'headers' => [
'X-Api-Key' => $this->apiKey,
'X-Api-Secret' => $this->apiSecret,
'Content-Type' => 'application/json',
],
'json' => [
'amount' => $amount,
'currency' => 'XOF',
'orderReference' => $reference,
'description' => 'Accès premium 30 jours',
'returnUrl' => $this->returnUrl,
'cancelUrl' => $this->cancelUrl,
'webhookUrl' => $this->webhookUrl,
'country' => 'SN',
'metadata' => [
'user_id' => $user->getId(),
'user_email' => $user->getEmail(),
],
'expiresInMinutes' => 60,
],
]);
$data = json_decode($response->getContent(), true);
// Rediriger le client vers la page de paiement
return [
'checkoutUrl' => $data['checkoutUrl'],
'sessionToken' => $data['sessionToken'],
];
}
Les statuts de paiement
| Statut | Description | Action côté marchand |
|---|---|---|
Open |
Session créée, en attente de paiement | Afficher la page de paiement |
Processing |
Paiement initié, en cours | Attendre le webhook |
Complete |
✅ Paiement réussi | Activer le premium |
Failed |
❌ Paiement échoué | Notifier le client |
Expired |
⏰ Session expirée | Permettre un nouvel essai |
Test en mode Sandbox
SenePay propose un environnement sandbox avec des numéros de test :
Pays : Sénégal (SN)
- 700000001 → Succès (Complete)
- 700000002 → En attente (Processing → Complete après 10s)
- 700000003 → Échec (Failed)
Codes OTP de test (Orange Money) :
- 123456 → Succès
- 000000 → Échec
- 111111 → Échec
Les bonnes pratiques retenues
- Adapter le moyen de paiement au marché : mobile money au Sénégal, pas de carte bancaire
- Un voter Symfony centralise proprement le contrôle d'accès premium
- Le webhook est la seule source de vérité : le retour navigateur n'est que de l'UX
- Vérifier la signature HMAC pour sécuriser les webhooks
- Idempotence : traiter chaque transaction une seule fois
- Métadata : passer l'ID utilisateur dans les métadonnées pour le retrouver dans le webhook
- Expiration automatique : le premium expire après 30 jours
Conclusion
L'intégration de SenePay m'a permis de monétiser baccalaureat.sn tout en restant accessible au marché sénégalais. L'architecture avec webhook sécurisé et voter Symfony garantit une gestion fiable des accès premium.
Prochain article : Je vous montrerai comment j'ai intégré l'API Direct SenePay pour gérer les paiements sans page de checkout hébergée.