Intégration

Bictorys Payment API — Guide d'intégration

📘

Guide complet pour intégrer l'API Bictorys dans n'importe quelle application : paiements (charges), retraits (payouts), webhooks, flow OTP Orange Money CI et tous les opérateurs Mobile Money d'Afrique de l'Ouest.

Basé sur des tests réels — mars 2026.


Table des matières

  1. Configuration & Variables d'environnement
  2. Authentification
  3. Créer un paiement (Charge)
  4. Flow OTP — Orange Money Côte d'Ivoire
  5. Vérifier le statut d'une transaction
  6. Webhooks — Recevoir les notifications
  7. Créer un retrait (Payout)
  8. Moyens de paiement supportés
  9. Normalisation pays
  10. Erreurs courantes & Debugging
  11. Checklist de mise en production
  12. Exemples de code complets

1. Configuration & Variables d'environnement

🔑 Clés et variables nécessaires

VariableUsageOù la trouver
BICTORYS_API_URLBase URL de l'APIVoir tableau ci-dessous
BICTORYS_API_KEYClé publique — création de charges et vérification de statutsDashboard → Developers → API Keys → Public Key
BICTORYS_PRIVATE_KEYClé privée — payouts et lecture des payment methodsDashboard → Developers → API Keys → Private Key
BICTORYS_WEBHOOK_SECRETSecret dédié — validation des webhooks entrantsDashboard → Developers → Webhooks → Secret Key
BICTORYS_MERCHANT_SECRET_CODECode secret marchand — requis dans le body des payoutsDashboard → Entreprise → Préférences

🌍 URLs par environnement

EnvironnementBase URLPréfixe des clés
Test (Sandbox)https://api.test.bictorys.comtest_public-..., test_secret-...
Productionhttps://api.bictorys.compublic-..., secret-...

📄 Exemple .env

BICTORYS_API_URL=https://api.bictorys.com
BICTORYS_API_KEY=public-XXXX.YYYY
BICTORYS_PRIVATE_KEY=secret-XXXX.YYYY
BICTORYS_WEBHOOK_SECRET=votre_secret_webhook
BICTORYS_MERCHANT_SECRET_CODE=1234

⚠️

Comprendre les 3 secrets distincts

  • BICTORYS_API_KEY (publique) — Header X-Api-Key pour créer des charges et vérifier des transactions. C'est la clé principale pour les paiements entrants.
  • BICTORYS_PRIVATE_KEY (privée) — Header X-API-Key pour les payouts (retraits) et la lecture des opérateurs activés. Ne JAMAIS exposer côté client.
  • BICTORYS_WEBHOOK_SECRET — Secret dédié aux webhooks, envoyé par Bictorys dans le header X-Secret-Key. Ce n'est pas la private key — c'est un secret séparé configuré dans le dashboard webhooks.

2. Authentification

Toutes les requêtes API utilisent le header X-Api-Key (ou X-API-Key — les deux fonctionnent).

X-Api-Key: <votre_clé>
Content-Type: application/json

Quelle clé pour quelle opération ?

OpérationClé à utiliser
Charges (paiements entrants)Clé publique (BICTORYS_API_KEY)
Status check (GET /charges/{id})Clé publique (BICTORYS_API_KEY)
Payouts (retraits)Clé privée (BICTORYS_PRIVATE_KEY)
Payment methods (lecture opérateurs activés)Clé privée (BICTORYS_PRIVATE_KEY)
Webhooks (réception)Bictorys envoie X-Secret-Key dans sa requête

⚠️

Erreur fréquente — Utiliser la clé publique pour un payout retourne 403 "Access right not sufficient". Utiliser la clé privée pour une charge fonctionne, mais ce n'est pas recommandé.


3. Créer un paiement (Charge)

Endpoints

Direct API (intégration TPE, app mobile)

POST {BICTORYS_API_URL}/pay/v1/charges?payment_type={type}

Redirect Bictorys Page (intégration web, plugin)

POST {BICTORYS_API_URL}/pay/v1/charges

💡

Pour personnaliser l'apparence de la page de paiement Bictorys : https://docs.bictorys.com/docs/how-checkout-works

Headers

HeaderValeur
X-Api-KeyBICTORYS_API_KEY (clé publique)
Content-Typeapplication/json

Payment Types (query parameter payment_type)

ValeurDescription
wave_moneyPaiement Wave
orange_moneyPaiement Orange Money
mtn_moneyPaiement MTN Money
moovPaiement Moov Money
togocellPaiement Togocell
mobicashPaiement Mobicash
maxitPaiement Maxit (SN)
cardCarte bancaire (Visa/Mastercard)

Body (JSON)

{
  "amount": 5000,
  "currency": "XOF",
  "country": "SN",
  "paymentReference": "ORDER-ABC123",
  "successRedirectUrl": "https://monsite.com/success?ref=ORDER-ABC123",
  "ErrorRedirectUrl": "https://monsite.com/error?ref=ORDER-ABC123",
  "customerObject": {
    "name": "Amadou Fall",
    "phone": "+221771234567",
    "email": "[email protected]",
    "country": "SN"
  },
  "otp": "123456"
}

Paramètres du body

ChampTypeRequisDescription
amountintegerMontant en FCFA (entier, sans décimales). Minimum : 100
currencystringToujours "XOF" pour le franc CFA
countrystringCode pays Bictorys : "SN", "CI", "BK", "ML", "TG", "BJ"
paymentReferencestringRéférence unique de votre commande
successRedirectUrlstringURL de redirection après paiement réussi
ErrorRedirectUrlstringURL de redirection après échec
customerObjectobjectInformations client (recommandé)
customerObject.namestringNom du client
customerObject.phonestringTéléphone format "+221771234567" (sans espaces)
customerObject.emailstringEmail du client
customerObject.countrystringCode pays du client
otpstringCode OTP — Orange Money CI uniquement (voir §4)

⚠️

Format du téléphone

Le numéro de téléphone n'est pas obligatoire pour les payment_type wave, orange_money et maxit au Sénégal.

Le format requis est +INDICATIF + NUMERO collé, sans espaces :

  • "+221771234567" (Sénégal)
  • "+2250701234567" (Côte d'Ivoire)
  • "221771234567" (manque le +)
  • "771234567" (pas de préfixe pays)
  • "+221 77 123 45 67" (espaces interdits)

Ce format s'applique aux charges et aux payouts.

Réponse — 201 Created

{
  "transactionId": "33e1c83b-7cb0-437b-bc50-a7a58e5660ad",
  "redirectUrl": "https://pay.bictorys.com/checkout/33e1c83b-...",
  "link": "https://pay.bictorys.com/link/...",
  "qrCode": "data:image/png;base64,...",
  "message": "Composez *144*82# pour valider..."
}
ChampTypeQuand présentUsage
transactionIdstring (UUID)ToujoursID unique Bictorys
redirectUrlstringToujoursURL de redirection (fallback général)
linkstringWave, CarteLien direct (deep link Wave ou page checkout carte)
qrCodestring (base64 PNG)WaveQR code à afficher pour desktop/TPE
messagestringOrange CI, MTN CI, Orange SNMessage USSD à afficher à l'utilisateur

Flux UX selon l'opérateur

OpérateurFlux recommandé
Wave (mobile)Rediriger vers link → deep link app Wave → paiement → webhook
Wave (desktop)Afficher qrCode dans un modal + polling → scan utilisateur → webhook
Orange Money CIStep OTP dédié (#144*82#) → envoyer otp → afficher message + polling → webhook
Orange Money BFAfficher message USSD + polling → validation téléphone → webhook
MTN Money CIAfficher message + polling → validation téléphone → webhook
CarteRediriger vers link → page checkout → saisie carte → 3DS → webhook

Réponses d'erreur

HTTP StatusCauseAction
400Paramètres invalides, "wrong payment type", "country not available"Vérifier body et query params
401Clé API invalide ou manquanteVérifier X-Api-Key
403 (HTML)WAF rate-limitRetry avec backoff exponentiel
403 (JSON)"Access right not sufficient"Mauvaise clé (publique vs privée)
500Erreur interne BictorysRéessayer plus tard

4. Flow OTP — Orange Money Côte d'Ivoire

💡

Orange Money CI et BF sont les seuls opérateurs qui nécessitent un code OTP généré par le client.

Étapes du flow

  1. L'utilisateur compose #144*82# sur son téléphone Orange CI
  2. Il reçoit un code OTP (6-8 chiffres)
  3. Il saisit ce code dans votre formulaire de paiement
  4. Vous envoyez le code dans le champ otp du body de la charge
  5. Bictorys retourne un message USSD
  6. L'utilisateur valide sur son téléphone
  7. Bictorys envoie un webhook avec le statut final

Détection côté code

const needsOtp = paymentType === "orange_money" && (country === "CI" || country === "BF");

Envoi dans le body

const body: Record<string, unknown> = {
  amount,
  currency,
  country,
  paymentReference,
  successRedirectUrl,
  ErrorRedirectUrl,
  customerObject,
};

// Uniquement pour Orange Money CI / BF
if (otp) {
  body.otp = otp;
}

Conseils UX

  • Afficher un step dédié pour la saisie OTP, avant le paiement
  • Expliquer clairement : _« Composez #144_82# sur votre téléphone pour générer votre code OTP »*
  • En cas d'échec → ramener à l'étape OTP (pas au formulaire complet) pour faciliter le retry
  • L'OTP expire rapidement — l'utilisateur devra peut-être recomposer #144*82#

5. Vérifier le statut d'une transaction

💡

Quand utiliser le polling ? Pour les TPE ou applications sans backend. Si vous avez un backend, privilégiez les webhooks (voir §6).

Endpoint

GET {BICTORYS_API_URL}/pay/v1/transactions/{transactionId}/status

Headers

HeaderValeur
X-Api-KeyBICTORYS_API_KEY (clé publique)

Statuts possibles

StatutDescriptionAction recommandée
succeededPaiement confirmé✅ Valider la commande
authorizedPaiement autorisé (pré-capture carte)✅ Traiter comme succeeded
pendingEn attente de validation client⏳ Continuer le polling
processingEn cours de traitement⏳ Continuer le polling
failedPaiement échoué❌ Marquer FAILED
cancelledAnnulé par le client❌ Marquer FAILED
reversedRemboursé/annulé après succès❌ Marquer FAILED

6. Webhooks — Recevoir les notifications

Configuration dans le dashboard

  1. Dashboard → DevelopersWebhooks
  2. Ajouter votre URL : https://votre-api.com/webhooks/bictorys
  3. Renseigner le Secret Key
  4. Sauvegarder

⚠️

Test vs Production

La configuration webhook est séparée entre les modes test et production. Le webhook configuré en test n'est pas actif en production, et inversement. Configurez les deux environnements.

Headers envoyés par Bictorys

POST https://votre-api.com/webhooks/bictorys
Content-Type: application/json
X-Secret-Key: <votre_webhook_secret>          # toujours présent
X-Webhook-Signature: <hmac_sha256_hex>         # optionnel (si HMAC activé)
X-Webhook-Timestamp: <unix_timestamp_ms>       # optionnel (si HMAC activé)

Validation de la signature

Méthode 1 — HMAC-SHA256 (recommandée)

À utiliser si X-Webhook-Signature est présent.

import crypto from "crypto";

function verifyHmacSignature(
  rawBody: string,
  secret: string,
  signature: string,
  timestamp: string
): boolean {
  // 1. Replay protection — rejeter si timestamp > 5 minutes
  const ts = parseInt(timestamp, 10);
  if (isNaN(ts) || Math.abs(Date.now() - ts) > 5 * 60 * 1000) {
    return false;
  }

  // 2. Calcul du HMAC
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  // 3. Comparaison timing-safe
  try {
    return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  } catch {
    return false;
  }
}

Méthode 2 — Static key (fallback)

À utiliser si X-Webhook-Signature n'est pas envoyé.

function verifyStaticKey(secretKeyHeader: string, expectedSecret: string): boolean {
  try {
    return crypto.timingSafeEqual(
      Buffer.from(secretKeyHeader),
      Buffer.from(expectedSecret)
    );
  } catch {
    return false;
  }
}

🔒

Sécurité critique — Toujours utiliser crypto.timingSafeEqual(), jamais === pour comparer des secrets (vulnérabilité timing attack).

Payload du webhook

{
  "id": "33e1c83b-7cb0-437b-bc50-a7a58e5660ad",
  "merchantId": "d2d2053b-638d-4133-957e-3caf63e6b79c",
  "type": "payment",
  "amount": 5000,
  "currency": "XOF",
  "paymentReference": "ORDER-ABC123",
  "customerId": "fbd2053b-...",
  "customerObject": {
    "id": "fbd2053b-...",
    "name": "Amadou Fall",
    "phone": 221771234567,
    "email": "[email protected]",
    "address": "",
    "city": "Dakar",
    "postalCode": 0,
    "country": "SN",
    "locale": "fr-FR",
    "createdAt": "2026-03-01T12:00:00Z",
    "updatedAt": "2026-03-01T12:00:00Z"
  },
  "pspName": "wave_money",
  "paymentMeans": "+221 *** ** 67",
  "paymentChannel": "Terminal",
  "merchantFees": 150,
  "customerFees": 0,
  "merchantReference": "ORDER-ABC123",
  "status": "succeeded",
  "timestamp": "2026-03-01T12:05:00Z"
}

Champs clés

📖

Documentation officielle : https://docs.bictorys.com/docs/how-to-validate-webhooks

ChampTypeDescription
idstring (UUID)ID unique de la transaction Bictorys
statusstring"succeeded", "failed", "cancelled", "authorized", "reversed"
paymentReferencestringVotre référence de commande
amountintegerMontant en FCFA
currencystring"XOF"
pspNamestringOpérateur utilisé ("wave_money", "orange_money", etc.)
merchantFeesintegerFrais Bictorys facturés au marchand
customerFeesintegerFrais facturés au client
merchantReferencestringMême valeur que paymentReference
timestampstring (ISO 8601)Date/heure de la transaction

Bonnes pratiques d'implémentation

Checklist webhook

  1. Body raw — Recevoir le body en Buffer brut, avant le JSON parser global (express.raw() avant express.json())
  2. Vérifier la signature — HMAC ou static key avec timingSafeEqual
  3. Logger en base — Avant tout traitement, pour debug et audit
  4. Anti-fraude — Vérifier que amount + currency correspondent à votre commande
  5. Idempotency — Ne pas traiter deux fois le même webhook (table de logs + transaction Serializable)
  6. Toujours retourner HTTP 200 — Même en cas d'erreur interne, sinon Bictorys réessaiera 3 fois

Implémentation Express.js

import express from "express";
import crypto from "crypto";

const app = express();

// ⚠️ ORDRE CRITIQUE : raw AVANT json
app.use("/webhooks", express.raw({ type: "application/json" }));
app.use(express.json());

app.post("/webhooks/bictorys", async (req, res) => {
  try {
    const rawBody = Buffer.isBuffer(req.body)
      ? req.body.toString("utf-8")
      : req.body;

    const signature = req.headers["x-webhook-signature"] as string | undefined;
    const timestamp = req.headers["x-webhook-timestamp"] as string | undefined;
    const secretKey = req.headers["x-secret-key"] as string | undefined;

    // Vérification de la signature
    let isValid = false;
    if (signature && timestamp) {
      isValid = verifyHmacSignature(rawBody, WEBHOOK_SECRET, signature, timestamp);
    } else if (secretKey) {
      isValid = verifyStaticKey(secretKey, WEBHOOK_SECRET);
    }

    if (!isValid) {
      console.error("Webhook signature invalid");
      res.status(200).json({ received: true }); // 200 quand même
      return;
    }

    const payload = JSON.parse(rawBody);
    const { id, status, paymentReference, amount, currency } = payload;

    // → Logger, vérifier anti-fraude, traiter de manière idempotente
    // → Votre logique métier ici

    res.status(200).json({ received: true });
  } catch (error) {
    console.error("Webhook error:", error);
    res.status(200).json({ received: true }); // TOUJOURS 200
  }
});

7. Créer un retrait (Payout)

Les payouts permettent d'envoyer de l'argent vers un compte Mobile Money.

Endpoint

POST {BICTORYS_API_URL}/pay/v1/payouts?payment_type={type}

Headers

HeaderValeur
X-API-KeyBICTORYS_PRIVATE_KEY (clé privée — obligatoire)
Content-Typeapplication/json
acceptapplication/json
idempotency-keyUUID unique par retrait (empêche les doublons)

🔒

La clé publique retourne 401 sur les payouts. Seule la clé privée fonctionne.

Payment types

ValeurDescription
wave_moneyRetrait vers Wave
orange_moneyRetrait vers Orange Money
mtn_moneyRetrait vers MTN Money
moovRetrait vers Moov Money

Body (JSON)

{
  "amount": 10000,
  "currency": "XOF",
  "country": "SN",
  "customerObject": {
    "name": "Amadou Fall",
    "phone": "+221771234567",
    "email": "[email protected]",
    "country": "SN",
    "locale": "fr-FR"
  },
  "transactionType": "payment",
  "paymentReason": "Appel de fonds",
  "merchantReference": "WD-ABC123",
  "merchant": {
    "secretCode": "1234"
  }
}

Paramètres du body

ChampTypeRequisDescription
amountintegerMontant en FCFA (entier)
currencystring"XOF"
countrystring"SN", "CI", "BJ", "ML", "TG", "BF"
customerObjectobjectDestinataire du payout
customerObject.phonestringTéléphone format "+221771234567"
customerObject.namestringNom du destinataire
transactionTypestring"payment"
paymentReasonstringMotif du retrait
merchantReferencestringVotre référence unique
merchant.secretCodestringBICTORYS_MERCHANT_SECRET_CODE

Réponse — 200 OK ou 201 Created

{
  "id": "abc123-def456",
  "merchantId": "d2d2053b-...",
  "amount": -10000,
  "merchantFee": 150,
  "customerFee": 0,
  "currency": "XOF",
  "paymentReference": "...",
  "customerName": "Amadou Fall",
  "customerPhone": "221771234567",
  "customerCountry": "SN",
  "pspName": "wave_money",
  "merchantReference": "WD-ABC123",
  "status": 0,
  "createdAt": "2026-03-01T12:00:00Z"
}

💡

À noter

  • amount est négatif (argent sortant du marchand)
  • status: 0 = succès
  • merchantFee = frais Bictorys sur le transfert

Erreurs courantes

HTTPBody contientSignification
401Mauvaise clé (utiliser PRIVATE_KEY, pas API_KEY)
400"balance"Solde Bictorys insuffisant
400"plafon" ou "limit"Plafond Mobile Money du destinataire atteint
400"phone"Numéro de téléphone invalide
400"secretCode"Code marchand incorrect
500+Erreur serveur Bictorys — header idempotency-key absent

Recommandations payout

  • Toujours envoyer un idempotency-key (UUID) pour éviter les doubles envois
  • Mettre un timeout de 30 secondes sur la requête (les payouts peuvent être lents)
  • Gérer les réponses non-JSON (le WAF peut retourner du HTML)
  • Logger la réponse brute pour le debug

8. Moyens de paiement supportés

Lister vos opérateurs activés

GET {BICTORYS_API_URL}/onboarding/v1/payment-methods/me
X-API-Key: BICTORYS_PRIVATE_KEY

Catalogue complet (mars 2026)

Nom internepayment_typePaysPay-inPay-outTéléphone requis
wave_moneywave_moneySNnon
wave_money_civwave_moneyCI, BFoui
orange_money_snorange_moneySNnon
orange_money_civorange_moneyCIoui
orange_money_mlorange_moneyMLoui
orange_money_bkorange_moneyBKoui
mtn_moneymtn_moneyCI, BJoui
moovmoovTG, CI, BF, BJoui
togocelltogocellTGoui
mobicashmobicashBF, MLoui
maxitmaxitSNnon
cardcardSN, CInon

Résultats des tests réels — sandbox, mars 2026

Opérateur + PaysRésultatRemarque
Wave SN
Wave CI
Orange Money SN
Orange Money CI (OTP)Requiert otp dans le body
MTN Money CI
Card SN / CI / BF&payment_category=card
Wave BF⚠️"wrong payment type"
Orange Money BK⚠️"wrong payment type"
Orange Money ML⚠️"country not available"
MTN Money BJ⚠️"country not available"
Moov CI⚠️"Unexpected value 'moov'"
Moov TG / BF / BJ⚠️"country not available"
Togocell TG⚠️"country not available"
Mobicash BF / ML⚠️"wrong payment type" / "country not available"

💡

Conclusion

En mars 2026, les opérateurs fonctionnels en sandbox sont : Wave (SN, CI), Orange Money (SN, CI), MTN Money (CI) et Carte (SN, CI, BF).

Les autres pays/opérateurs retournent des erreurs.

La carte fonctionne pour tous les pays car Bictorys normalise le pays en interne.


9. Normalisation pays

Codes pays Bictorys

CodePaysIndicatif
SNSénégal+221
CICôte d'Ivoire+225
BKBurkina Faso+226
MLMali+223
TGTogo+228
BJBénin+229

Détection automatique par indicatif

+221... → SN (Sénégal)
+225... → CI (Côte d'Ivoire)
+226... → BF (Burkina Faso)
+223... → ML (Mali)
+228... → TG (Togo)
+229... → BJ (Bénin)

10. Erreurs courantes & Debugging

❌ WAF 403 — Réponse HTML au lieu de JSON

Cause : Rate limit AWS WAF. La réponse est du HTML (<html>... Forbidden ...).

Solution : Retry avec backoff exponentiel. En test, espacer les requêtes de 5 secondes.

❌ 403 JSON — "Access right not sufficient"

Cause : Mauvaise clé. Clé publique utilisée pour un payout, ou clé privée d'un autre compte.

Solution : Vérifier la bonne clé pour chaque opération (voir §2).

❌ 400 — "wrong payment type"

Cause : L'opérateur n'est pas activé pour ce pays sur votre compte Bictorys.

Solution : Vérifier via GET /onboarding/v1/payment-methods/me.

❌ 400 — "country not available"

Cause : Le pays n'est pas activé pour cet opérateur sur votre compte.

Solution : Contacter Bictorys pour activer le pays.

❌ Webhook non reçu

Causes possibles :

  • Webhook pas configuré (ou configuré en test mais pas en prod)
  • URL non accessible depuis Internet
  • Secret Key ne correspond pas
  • Le mode (test/prod) ne correspond pas aux clés API utilisées

Solution : Vérifier dans le dashboard Bictorys → Developers → Webhooks que l'URL et le secret sont corrects pour le bon environnement.

❌ 500 sur GET /charges/{id} en test

Cause : Comportement connu de la sandbox Bictorys.

Solution : Se baser sur les webhooks en environnement test. Le status check fonctionne en production.

❌ OTP invalide / expiré (Orange Money CI)

Cause : Le code OTP expire rapidement.

Solution : L'utilisateur doit recomposer #144*82# pour générer un nouveau code.

❌ Réponse 200 vide (pas de JSON)

Cause : WAF en mode test qui bloque silencieusement les requêtes trop rapides.

Solution : Ajouter un délai de 5 secondes entre les requêtes en test.


11. Checklist de mise en production

🏢 Dashboard Bictorys

  • Mode Production activé
  • Clés API de production obtenues (pas de préfixe test_)
  • Webhook configuré en mode production avec URL et secret corrects
  • Micro-paiement test validé (500 FCFA) en production

🔧 Variables d'environnement

  • BICTORYS_API_URLhttps://api.bictorys.com
  • BICTORYS_API_KEY → clé publique production (préfixe public-)
  • BICTORYS_PRIVATE_KEY → clé privée production (préfixe secret-)
  • BICTORYS_WEBHOOK_SECRET → secret webhook production
  • BICTORYS_MERCHANT_SECRET_CODE → code marchand (si payouts)

🔒 Sécurité

  • Validation signature webhook (HMAC-SHA256 ou static key avec timingSafeEqual)
  • Anti-fraude webhook : vérification montant + devise
  • Idempotency webhook : transaction Serializable + table de logs
  • Retry WAF 403 : backoff exponentiel implémenté
  • Payout : idempotency-key envoyé + timeout 30s
  • express.raw() avant express.json() pour les webhooks
  • Toujours retourner 200 sur le webhook (même en cas d'erreur)
  • Clé privée jamais exposée côté client
  • Polling fallback implémenté si webhook n'arrive pas

12. Exemples de code complets

Provider TypeScript (Node.js / Express)

// bictorys-provider.ts
import crypto from "crypto";

const API_URL = process.env.BICTORYS_API_URL!;
const API_KEY = process.env.BICTORYS_API_KEY!;
const PRIVATE_KEY = process.env.BICTORYS_PRIVATE_KEY!;
const WEBHOOK_SECRET = process.env.BICTORYS_WEBHOOK_SECRET!;
const MERCHANT_SECRET_CODE = process.env.BICTORYS_MERCHANT_SECRET_CODE!;

// ─── Créer un paiement ──────────────────────────────────────

interface CreateChargeParams {
  amount: number;
  currency: "XOF";
  country: string;
  paymentType: string;
  paymentReference: string;
  successRedirectUrl: string;
  errorRedirectUrl: string;
  customer?: {
    name: string;
    phone: string;
    email: string;
    country: string;
  };
  otp?: string;
}

interface ChargeResult {
  transactionId: string;
  redirectUrl: string;
  link?: string;
  qrCode?: string;
  message?: string;
}

async function createCharge(params: CreateChargeParams): Promise<ChargeResult> {
  // Pour les cartes, ajouter payment_category=card
  const queryParams =
    params.paymentType === "card"
      ? `payment_type=card&payment_category=card`
      : `payment_type=${params.paymentType}`;

  const url = `${API_URL}/pay/v1/charges?${queryParams}`;

  const body: Record<string, unknown> = {
    amount: params.amount,
    currency: params.currency,
    country: params.country,
    paymentReference: params.paymentReference,
    successRedirectUrl: params.successRedirectUrl,
    ErrorRedirectUrl: params.errorRedirectUrl, // ⚠️ E majuscule
    customerObject: params.customer,
  };

  // Champ otp uniquement pour Orange Money CI / BF
  if (params.otp) body.otp = params.otp;

  const MAX_RETRIES = 3;
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
    // Backoff exponentiel sur les retries (WAF)
    if (attempt > 0) {
      await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 1000));
    }

    const response = await fetch(url, {
      method: "POST",
      headers: {
        "X-Api-Key": API_KEY,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    });

    if (response.ok) return await response.json();

    const errorText = await response.text();
    // WAF 403 → retry
    if (
      response.status === 403 &&
      errorText.includes("Forbidden") &&
      attempt < MAX_RETRIES
    ) {
      continue;
    }

    throw new Error(`Bictorys charge error (${response.status}): ${errorText}`);
  }

  throw new Error("Bictorys charge: max retries reached");
}

// ─── Vérifier le statut ─────────────────────────────────────

async function checkChargeStatus(transactionId: string): Promise<string> {
  const response = await fetch(`${API_URL}/pay/v1/charges/${transactionId}`, {
    headers: { "X-Api-Key": API_KEY },
  });

  if (!response.ok) throw new Error(`Status check error: ${response.status}`);

  const data = await response.json();
  return data.status; // "succeeded", "pending", "failed", etc.
}

// ─── Créer un payout ────────────────────────────────────────

interface PayoutParams {
  amount: number;
  paymentType: "wave_money" | "orange_money";
  phone: string;
  name: string;
  email: string;
  merchantReference: string;
}

async function createPayout(params: PayoutParams, idempotencyKey: string) {
  const url = `${API_URL}/pay/v1/payouts?payment_type=${params.paymentType}`;

  const body = {
    amount: params.amount,
    currency: "XOF",
    country: "SN",
    customerObject: {
      name: params.name,
      phone: params.phone,
      email: params.email,
      country: "SN",
      locale: "fr-FR",
    },
    transactionType: "payment",
    paymentReason: "Appel de fonds",
    merchantReference: params.merchantReference,
    merchant: { secretCode: MERCHANT_SECRET_CODE },
  };

  // Timeout 30s (les payouts peuvent être lents)
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 30000);

  try {
    const response = await fetch(url, {
      method: "POST",
      headers: {
        "X-API-Key": PRIVATE_KEY, // ⚠️ clé privée obligatoire
        "Content-Type": "application/json",
        accept: "application/json",
        "idempotency-key": idempotencyKey,
      },
      body: JSON.stringify(body),
      signal: controller.signal,
    });
    clearTimeout(timeout);

    if (response.ok) return { success: true, data: await response.json() };

    const errorText = await response.text();
    return {
      success: false,
      error: errorText,
      httpStatus: response.status,
    };
  } catch (error) {
    clearTimeout(timeout);
    throw error;
  }
}

// ─── Vérifier un webhook ────────────────────────────────────

function verifyWebhook(
  rawBody: string,
  headers: Record<string, string | undefined>
): boolean {
  const signature = headers["x-webhook-signature"];
  const timestamp = headers["x-webhook-timestamp"];
  const secretKey = headers["x-secret-key"];

  // Méthode 1 : HMAC-SHA256 (recommandée)
  if (signature && timestamp) {
    const ts = parseInt(timestamp, 10);
    // Replay protection : 5 minutes
    if (isNaN(ts) || Math.abs(Date.now() - ts) > 5 * 60 * 1000) return false;

    const expected = crypto
      .createHmac("sha256", WEBHOOK_SECRET)
      .update(`${timestamp}.${rawBody}`)
      .digest("hex");

    try {
      return crypto.timingSafeEqual(
        Buffer.from(signature),
        Buffer.from(expected)
      );
    } catch {
      return false;
    }
  }

  // Méthode 2 : Static key (fallback)
  if (secretKey) {
    try {
      return crypto.timingSafeEqual(
        Buffer.from(secretKey),
        Buffer.from(WEBHOOK_SECRET)
      );
    } catch {
      return false;
    }
  }

  return false;
}

// ─── Détecter le pays depuis le téléphone ───────────────────

function detectCountryFromPhone(phone: string): string | null {
  if (phone.startsWith("+221") || phone.startsWith("221")) return "SN";
  if (phone.startsWith("+225") || phone.startsWith("225")) return "CI";
  if (phone.startsWith("+226") || phone.startsWith("226")) return "BF";
  if (phone.startsWith("+223") || phone.startsWith("223")) return "ML";
  if (phone.startsWith("+228") || phone.startsWith("228")) return "TG";
  if (phone.startsWith("+229") || phone.startsWith("229")) return "BJ";
  return null;
}


💡

Dernière mise à jour : mars 2026 — Basé sur des tests réels avec Bictorys API v1, environnements test et production.