Il contesto
Su baccalaureat.sn, avevo già integrato il Checkout ospitato di SenePay: redirect verso checkout.sene-pay.com, webhook, finito in un pomeriggio. Semplice, ma con un compromesso che non mi piaceva — il cliente abbandona la mia interfaccia, nel bel mezzo del componente React del paywall.
Il sintomo
Con il Checkout ospitato, impossibile mantenere l'utente sul mio form:
// Checkout hébergé : redirection obligatoire
const data = await response.json();
window.location.href = data.checkoutUrl; // sortie du site
Su un'app mobile nativa, questo significa una WebView. Sul web, una rottura visiva con la mia identità grafica. Sono passato all'API Direct per mantenere il controllo su tutto il percorso, tranne per Wave che impone un redirect lato PSP.
Perché si gestisce diversamente
L'API Direct non reindirizza — restituisce un campo nextAction che stabilisce cosa mostrare lato frontend. Senza trattare questo campo come un'enum, si finisce per codificare a mano "Orange = OTP", "Wave = redirect", una lista che si rompe appena SenePay aggiunge un operatore.
enum NextAction: string
{
    case RedirectToProviderLink = 'REDIRECT_TO_PROVIDER_LINK';
    case UssdPush = 'USSD_PUSH';
    case OtpRequired = 'OTP_REQUIRED';
    case None = 'NONE';
}
AUn'altra trappola: su un pagamento rifiutato dall'operatore, SenePay risponde con HTTP 200 con status: "Failed" nel corpo. Il campo statut (con la t) resta true finché la richiesta è valida — non è un indicatore di successo del pagamento.
La soluzione
// 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);
}
Lato controller, verifico sempre status, mai 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é');
}
PPer Orange Money (SN, CI, BF, GN), il flusso richiede una seconda chiamata con il codice OTP ricevuto via 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
PPer la conferma finale, mi baso sul webhook invece che su un polling puro — stesso formato e stessa firma HMAC del Checkout ospitato:
#[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]);
}
PPunto che mi è costato due tentativi in sandbox: l'HMAC si calcola sul corpo grezzo della richiesta, con il webhookSigningSecret (prefisso whsec_) — mai con X-Api-Secret. Due segreti diversi, una confusione facile.
Cosa ne ricavo
  • nextAction va trattato come un'enum, mai come una lista di operatori codificata a mano.
  • UUn pagamento rifiutato restituisce HTTP 200 — ispezionare status, non statut.
  • L'HMAC del webhook usa webhookSigningSecret, non X-Api-Secret.
  • In sandbox, il numero di test dell'API Direct richiede il prefisso completo (221700000001), non il formato locale del Checkout.