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
- Configuration & Variables d'environnement
- Authentification
- Créer un paiement (Charge)
- Flow OTP — Orange Money Côte d'Ivoire
- Vérifier le statut d'une transaction
- Webhooks — Recevoir les notifications
- Créer un retrait (Payout)
- Moyens de paiement supportés
- Normalisation pays
- Erreurs courantes & Debugging
- Checklist de mise en production
- Exemples de code complets
1. Configuration & Variables d'environnement
🔑 Clés et variables nécessaires
| Variable | Usage | Où la trouver |
|---|---|---|
BICTORYS_API_URL | Base URL de l'API | Voir tableau ci-dessous |
BICTORYS_API_KEY | Clé publique — création de charges et vérification de statuts | Dashboard → Developers → API Keys → Public Key |
BICTORYS_PRIVATE_KEY | Clé privée — payouts et lecture des payment methods | Dashboard → Developers → API Keys → Private Key |
BICTORYS_WEBHOOK_SECRET | Secret dédié — validation des webhooks entrants | Dashboard → Developers → Webhooks → Secret Key |
BICTORYS_MERCHANT_SECRET_CODE | Code secret marchand — requis dans le body des payouts | Dashboard → Entreprise → Préférences |
🌍 URLs par environnement
| Environnement | Base URL | Préfixe des clés |
|---|---|---|
| Test (Sandbox) | https://api.test.bictorys.com | test_public-..., test_secret-... |
| Production | https://api.bictorys.com | public-..., secret-... |
📄 Exemple .env
.envBICTORYS_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) — HeaderX-Api-Keypour créer des charges et vérifier des transactions. C'est la clé principale pour les paiements entrants.BICTORYS_PRIVATE_KEY(privée) — HeaderX-API-Keypour 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 headerX-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ération | Clé à 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
| Header | Valeur |
|---|---|
X-Api-Key | BICTORYS_API_KEY (clé publique) |
Content-Type | application/json |
Payment Types (query parameter payment_type)
payment_type)| Valeur | Description |
|---|---|
wave_money | Paiement Wave |
orange_money | Paiement Orange Money |
mtn_money | Paiement MTN Money |
moov | Paiement Moov Money |
togocell | Paiement Togocell |
mobicash | Paiement Mobicash |
maxit | Paiement Maxit (SN) |
card | Carte 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
| Champ | Type | Requis | Description |
|---|---|---|---|
amount | integer | ✅ | Montant en FCFA (entier, sans décimales). Minimum : 100 |
currency | string | ✅ | Toujours "XOF" pour le franc CFA |
country | string | ✅ | Code pays Bictorys : "SN", "CI", "BK", "ML", "TG", "BJ" |
paymentReference | string | ✅ | Référence unique de votre commande |
successRedirectUrl | string | ✅ | URL de redirection après paiement réussi |
ErrorRedirectUrl | string | ✅ | URL de redirection après échec |
customerObject | object | ❌ | Informations client (recommandé) |
customerObject.name | string | ❌ | Nom du client |
customerObject.phone | string | ❌ | Téléphone format "+221771234567" (sans espaces) |
customerObject.email | string | ❌ | Email du client |
customerObject.country | string | ❌ | Code pays du client |
otp | string | ❌ | Code 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_typewave,orange_moneyetmaxitau Sénégal.Le format requis est
+INDICATIF+NUMEROcollé, 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
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..."
}
| Champ | Type | Quand présent | Usage |
|---|---|---|---|
transactionId | string (UUID) | Toujours | ID unique Bictorys |
redirectUrl | string | Toujours | URL de redirection (fallback général) |
link | string | Wave, Carte | Lien direct (deep link Wave ou page checkout carte) |
qrCode | string (base64 PNG) | Wave | QR code à afficher pour desktop/TPE |
message | string | Orange CI, MTN CI, Orange SN | Message USSD à afficher à l'utilisateur |
Flux UX selon l'opérateur
| Opérateur | Flux 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 CI | Step OTP dédié (#144*82#) → envoyer otp → afficher message + polling → webhook |
| Orange Money BF | Afficher message USSD + polling → validation téléphone → webhook |
| MTN Money CI | Afficher message + polling → validation téléphone → webhook |
| Carte | Rediriger vers link → page checkout → saisie carte → 3DS → webhook |
Réponses d'erreur
| HTTP Status | Cause | Action |
|---|---|---|
400 | Paramètres invalides, "wrong payment type", "country not available" | Vérifier body et query params |
401 | Clé API invalide ou manquante | Vérifier X-Api-Key |
403 (HTML) | WAF rate-limit | Retry avec backoff exponentiel |
403 (JSON) | "Access right not sufficient" | Mauvaise clé (publique vs privée) |
500 | Erreur interne Bictorys | Ré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
- L'utilisateur compose
#144*82#sur son téléphone Orange CI - Il reçoit un code OTP (6-8 chiffres)
- Il saisit ce code dans votre formulaire de paiement
- Vous envoyez le code dans le champ
otpdu body de la charge - Bictorys retourne un
messageUSSD - L'utilisateur valide sur son téléphone
- 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
| Header | Valeur |
|---|---|
X-Api-Key | BICTORYS_API_KEY (clé publique) |
Statuts possibles
| Statut | Description | Action recommandée |
|---|---|---|
succeeded | Paiement confirmé | ✅ Valider la commande |
authorized | Paiement autorisé (pré-capture carte) | ✅ Traiter comme succeeded |
pending | En attente de validation client | ⏳ Continuer le polling |
processing | En cours de traitement | ⏳ Continuer le polling |
failed | Paiement échoué | ❌ Marquer FAILED |
cancelled | Annulé par le client | ❌ Marquer FAILED |
reversed | Remboursé/annulé après succès | ❌ Marquer FAILED |
6. Webhooks — Recevoir les notifications
Configuration dans le dashboard
- Dashboard → Developers → Webhooks
- Ajouter votre URL :
https://votre-api.com/webhooks/bictorys - Renseigner le Secret Key
- 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
| Champ | Type | Description |
|---|---|---|
id | string (UUID) | ID unique de la transaction Bictorys |
status | string | "succeeded", "failed", "cancelled", "authorized", "reversed" |
paymentReference | string | Votre référence de commande |
amount | integer | Montant en FCFA |
currency | string | "XOF" |
pspName | string | Opérateur utilisé ("wave_money", "orange_money", etc.) |
merchantFees | integer | Frais Bictorys facturés au marchand |
customerFees | integer | Frais facturés au client |
merchantReference | string | Même valeur que paymentReference |
timestamp | string (ISO 8601) | Date/heure de la transaction |
Bonnes pratiques d'implémentation
Checklist webhook
- Body raw — Recevoir le body en
Bufferbrut, avant le JSON parser global (express.raw()avantexpress.json())- Vérifier la signature — HMAC ou static key avec
timingSafeEqual- Logger en base — Avant tout traitement, pour debug et audit
- Anti-fraude — Vérifier que
amount+currencycorrespondent à votre commande- Idempotency — Ne pas traiter deux fois le même webhook (table de logs + transaction Serializable)
- 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
| Header | Valeur |
|---|---|
X-API-Key | BICTORYS_PRIVATE_KEY (clé privée — obligatoire) |
Content-Type | application/json |
accept | application/json |
idempotency-key | UUID unique par retrait (empêche les doublons) |
La clé publique retourne
401sur les payouts. Seule la clé privée fonctionne.
Payment types
| Valeur | Description |
|---|---|
wave_money | Retrait vers Wave |
orange_money | Retrait vers Orange Money |
mtn_money | Retrait vers MTN Money |
moov | Retrait 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
| Champ | Type | Requis | Description |
|---|---|---|---|
amount | integer | ✅ | Montant en FCFA (entier) |
currency | string | ✅ | "XOF" |
country | string | ✅ | "SN", "CI", "BJ", "ML", "TG", "BF" |
customerObject | object | ✅ | Destinataire du payout |
customerObject.phone | string | ✅ | Téléphone format "+221771234567" |
customerObject.name | string | ✅ | Nom du destinataire |
transactionType | string | ✅ | "payment" |
paymentReason | string | ✅ | Motif du retrait |
merchantReference | string | ✅ | Votre référence unique |
merchant.secretCode | string | ✅ | BICTORYS_MERCHANT_SECRET_CODE |
Réponse — 200 OK ou 201 Created
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
amountest négatif (argent sortant du marchand)status: 0= succèsmerchantFee= frais Bictorys sur le transfert
Erreurs courantes
| HTTP | Body contient | Signification |
|---|---|---|
401 | — | Mauvaise 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 interne | payment_type | Pays | Pay-in | Pay-out | Téléphone requis |
|---|---|---|---|---|---|
wave_money | wave_money | SN | ✅ | ✅ | non |
wave_money_civ | wave_money | CI, BF | ✅ | ✅ | oui |
orange_money_sn | orange_money | SN | ✅ | ✅ | non |
orange_money_civ | orange_money | CI | ✅ | ✅ | oui |
orange_money_ml | orange_money | ML | ✅ | ✅ | oui |
orange_money_bk | orange_money | BK | ✅ | ✅ | oui |
mtn_money | mtn_money | CI, BJ | ✅ | ✅ | oui |
moov | moov | TG, CI, BF, BJ | ✅ | ✅ | oui |
togocell | togocell | TG | ✅ | ✅ | oui |
mobicash | mobicash | BF, ML | ✅ | ✅ | oui |
maxit | maxit | SN | ✅ | ✅ | non |
card | card | SN, CI | ✅ | ✅ | non |
Résultats des tests réels — sandbox, mars 2026
| Opérateur + Pays | Résultat | Remarque |
|---|---|---|
| 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
| Code | Pays | Indicatif |
|---|---|---|
SN | Sénégal | +221 |
CI | Côte d'Ivoire | +225 |
BK | Burkina Faso | +226 |
ML | Mali | +223 |
TG | Togo | +228 |
BJ | Bé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"
"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"
"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"
"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
GET /charges/{id} en testCause : 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_URL→https://api.bictorys.com -
BICTORYS_API_KEY→ clé publique production (préfixepublic-) -
BICTORYS_PRIVATE_KEY→ clé privée production (préfixesecret-) -
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-keyenvoyé + timeout 30s -
express.raw()avantexpress.json()pour les webhooks - Toujours retourner
200sur 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.
Updated about 14 hours ago