Le contexte
Sur baccalaureat.sn, j'avais déjà intégré le Checkout hébergé de SenePay : redirection vers checkout.sene-pay.com, webhook, terminé en une après-midi. Simple, mais avec un compromis que je n'aimais pas — le client quitte mon interface, en plein milieu du composant React de paywall.
Le symptôme
Avec le Checkout hébergé, impossible de garder l'utilisateur sur mon propre formulaire :
// Checkout hébergé : redirection obligatoire const data = await response.json(); window.location.href = data.checkoutUrl; // sortie du site
Sur une app mobile native, ça veut dire une WebView. Sur le web, une rupture visuelle avec ma charte graphique. Je suis passé à l'API Direct pour garder la main sur tout le parcours, sauf pour Wave qui impose une redirection côté PSP.
Pourquoi ça se pilote autrement
L'API Direct ne redirige pas — elle renvoie un champ nextAction qui dicte ce qu'il faut afficher côté frontend. Sans ce champ traité comme une enum, on finit par coder en dur "Orange = OTP", "Wave = redirect", une liste qui casse dès que SenePay ajoute un opérateur.
enum NextAction: string
{
case RedirectToProviderLink = 'REDIRECT_TO_PROVIDER_LINK';
case UssdPush = 'USSD_PUSH';
case OtpRequired = 'OTP_REQUIRED';
case None = 'NONE';
}Autre piège : sur un paiement refusé par l'opérateur, SenePay répond en HTTP 200 avec status: "Failed" dans le corps. Le champ statut (avec un t) reste true tant que la requête est valide — ce n'est pas un indicateur de succès du paiement.
La solution
// src/Service/Payment/SenePayDirectClient.php
public function initiate(array $payload): array
{
$response = $this->client->request('POST', $this->baseUrl . '/payments/initiate', [
'headers' => [
'X-Api-Key' => $this->apiKey,
'X-Api-Secret' => $this->apiSecret,
'Content-Type' => 'application/json',
],
'json' => $payload,
]);
return $response->toArray(false);
}Côté contrôleur, je vérifie toujours status, jamais statut :
$result = $this->senePayClient->initiate([
'amount' => $amount,
'currency' => 'XOF',
'country_code' => $countryCode,
'operator' => $operator,
'customer_phone' => $phone,
'order_id' => $orderReference,
'webhook_url' => $this->generateUrl('webhook_senepay_payin', [], UrlGeneratorInterface::ABSOLUTE_URL),
]);
if ($result['status'] === 'Failed') {
throw new PaymentFailedException($result['failedReason'] ?? 'Paiement refusé');
}Pour Orange Money (SN, CI, BF, GN), le flux demande un second appel avec le code OTP reçu par SMS :
// 1er appel : sans otp_code → nextAction === 'OTP_REQUIRED'
// 2e appel, même numéro, avec le code reçu par SMS :
$result = $this->senePayClient->initiate([
'amount' => 5000,
'country_code' => 'SN',
'operator' => 'orange',
'customer_phone' => $phone,
'otp_code' => $otpCode,
'order_id' => $orderReference,
]);
// nextAction devient USSD_PUSH, ou directement CompletedPour la confirmation finale, je m'appuie sur le webhook plutôt que sur du polling pur — même format et même signature HMAC que le Checkout hébergé :
#[Route('/webhooks/senepay', name: 'webhook_senepay_payin', methods: ['POST'])]
public function handle(Request $request): Response
{
$signature = $request->headers->get('X-SenePay-Signature');
$expected = hash_hmac('sha256', $request->getContent(), $this->webhookSecret);
if (!hash_equals($expected, $signature ?? '')) {
return new Response('Invalid signature', 401);
}
$payload = json_decode($request->getContent(), true);
if ($payload['event'] === 'checkout.session.completed') {
$this->orderService->markAsPaid($payload['orderReference'], $payload['netAmount']);
}
return new JsonResponse(['received' => true]);
}Point qui m'a coûté deux essais en sandbox : le HMAC se calcule sur le corps brut de la requête, avec le webhookSigningSecret (préfixe whsec_) — jamais avec X-Api-Secret. Deux secrets différents, une confusion facile.
Ce que je retiens
- nextAction se traite comme une enum, jamais comme une liste d'opérateurs codée en dur.
- Un paiement refusé renvoie du HTTP 200 — inspecter status, pas statut.
- Le HMAC webhook utilise webhookSigningSecret, pas X-Api-Secret.
- En sandbox, le numéro de test API Direct veut l'indicatif complet (221700000001), pas le format local du Checkout.