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 Completed
Pour 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.