Intégration-en
Bictorys Payment API — Integration Guide
Comprehensive guide for integrating the Bictorys API into any application: payments (charges), payouts, webhooks, Orange Money CI OTP flow, and all West African Mobile Money operators.
Based on real-world testing — March 2026.
Table of Contents
- Configuration & Environment Variables
- Authentication
- Creating a Payment (Charge)
- OTP Flow — Orange Money Côte d'Ivoire
- Checking Transaction Status
- Webhooks — Receiving Notifications
- Creating a Payout
- Supported Payment Methods
- Country Normalization
- Common Errors & Debugging
- Production Checklist
- Complete Code Examples
1. Configuration & Environment Variables
🔑 Required Keys and Variables
| Variable | Usage | Where to find it |
|---|---|---|
BICTORYS_API_URL | API base URL | See table below |
BICTORYS_API_KEY | Public key — creating charges and checking statuses | Dashboard → Developers → API Keys → Public Key |
BICTORYS_PRIVATE_KEY | Private key — payouts and reading payment methods | Dashboard → Developers → API Keys → Private Key |
BICTORYS_WEBHOOK_SECRET | Dedicated secret — validating incoming webhooks | Dashboard → Developers → Webhooks → Secret Key |
BICTORYS_MERCHANT_SECRET_CODE | Merchant secret code — required in payout request body | Dashboard → Company → Preferences |
🌍 URLs by Environment
| Environment | Base URL | Key Prefix |
|---|---|---|
| Test (Sandbox) | https://api.test.bictorys.com | test_public-..., test_secret-... |
| Production | https://api.bictorys.com | public-..., secret-... |
📄 Example .env
BICTORYS_API_URL=https://api.bictorys.com
BICTORYS_API_KEY=public-XXXX.YYYY
BICTORYS_PRIVATE_KEY=secret-XXXX.YYYY
BICTORYS_WEBHOOK_SECRET=your_webhook_secret
BICTORYS_MERCHANT_SECRET_CODE=1234
Understanding the 3 distinct secrets
BICTORYS_API_KEY(public) —X-Api-Keyheader for creating charges and verifying transactions. This is the main key for incoming payments.BICTORYS_PRIVATE_KEY(private) —X-API-Keyheader for payouts (withdrawals) and reading activated operators. NEVER expose client-side.BICTORYS_WEBHOOK_SECRET— Dedicated webhook secret, sent by Bictorys in theX-Secret-Keyheader. This is NOT the private key — it is a separate secret configured in the webhooks dashboard.
2. Authentication
All API requests use the X-Api-Key header (or X-API-Key — both work).
X-Api-Key: <your_key>
Content-Type: application/json
Which key for which operation?
| Operation | Key to use |
|---|---|
| Charges (incoming payments) | Public key (BICTORYS_API_KEY) |
Status check (GET /charges/{id}) | Public key (BICTORYS_API_KEY) |
| Payouts (withdrawals) | Private key (BICTORYS_PRIVATE_KEY) |
| Payment methods (reading activated operators) | Private key (BICTORYS_PRIVATE_KEY) |
| Webhooks (reception) | Bictorys sends X-Secret-Key in its request |
Common mistake — Using the public key for a payout returns
403 "Access right not sufficient". Using the private key for a charge works but is not recommended.
3. Creating a Payment (Charge)
Endpoints
Direct API (POS integration, mobile app)
POST {BICTORYS_API_URL}/pay/v1/charges?payment_type={type}
Bictorys Redirect Page (web integration, plugin)
POST {BICTORYS_API_URL}/pay/v1/charges
To customize the appearance of the Bictorys payment page: https://docs.bictorys.com/docs/how-checkout-works
Headers
| Header | Value |
|---|---|
X-Api-Key | BICTORYS_API_KEY (public key) |
Content-Type | application/json |
Payment Types (query parameter payment_type)
payment_type)| Value | Description |
|---|---|
wave_money | Wave payment |
orange_money | Orange Money payment |
mtn_money | MTN Money payment |
moov | Moov Money payment |
togocell | Togocell payment |
mobicash | Mobicash payment |
maxit | Maxit payment (SN) |
card | Bank card (Visa/Mastercard) |
Body (JSON)
{
"amount": 5000,
"currency": "XOF",
"country": "SN",
"paymentReference": "ORDER-ABC123",
"successRedirectUrl": "https://mysite.com/success?ref=ORDER-ABC123",
"ErrorRedirectUrl": "https://mysite.com/error?ref=ORDER-ABC123",
"customerObject": {
"name": "Amadou Fall",
"phone": "+221771234567",
"email": "[email protected]",
"country": "SN"
},
"otp": "123456"
}
Body Parameters
| Field | Type | Required | Description |
|---|---|---|---|
amount | integer | ✅ | Amount in FCFA (integer, no decimals). Minimum: 100 |
currency | string | ✅ | Always "XOF" for CFA franc |
country | string | ✅ | Bictorys country code: "SN", "CI", "BK", "ML", "TG", "BJ" |
paymentReference | string | ✅ | Your unique order reference |
successRedirectUrl | string | ✅ | Redirect URL after successful payment |
ErrorRedirectUrl | string | ✅ | Redirect URL after failure |
customerObject | object | ❌ | Customer information (recommended) |
customerObject.name | string | ❌ | Customer name |
customerObject.phone | string | ❌ | Phone in "+221771234567" format (no spaces) |
customerObject.email | string | ❌ | Customer email |
customerObject.country | string | ❌ | Customer country code |
otp | string | ❌ | OTP code — Orange Money CI only (see §4) |
Phone format
The phone number is not mandatory for
wave,orange_money, andmaxitpayment types in Senegal.The required format is
+COUNTRY_CODE + NUMBERconcatenated, no spaces:✅
"+221771234567"(Senegal)
✅"+2250701234567"(Côte d'Ivoire)
❌"221771234567"(missing the+)
❌"771234567"(no country prefix)
❌"+221 77 123 45 67"(spaces not allowed)This format applies to both charges and payouts.
Response — 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": "Dial *144*82# to confirm..."
}
| Field | Type | When present | Usage |
|---|---|---|---|
transactionId | string (UUID) | Always | Unique Bictorys ID |
redirectUrl | string | Always | Redirect URL (general fallback) |
link | string | Wave, Card | Direct link (Wave deep link or card checkout page) |
qrCode | string (base64 PNG) | Wave | QR code to display for desktop/POS |
message | string | Orange CI, MTN CI, Orange SN | USSD message to display to the user |
UX Flow by Operator
| Operator | Recommended flow |
|---|---|
| Wave (mobile) | Redirect to link → Wave app deep link → payment → webhook |
| Wave (desktop) | Display qrCode in a modal + polling → user scans → webhook |
| Orange Money CI | Dedicated OTP step (#144*82#) → send otp → display message + polling → webhook |
| Orange Money BF | Display USSD message + polling → phone validation → webhook |
| MTN Money CI | Display message + polling → phone validation → webhook |
| Card | Redirect to link → checkout page → card entry → 3DS → webhook |
Error Responses
| HTTP Status | Cause | Action |
|---|---|---|
| 400 | Invalid parameters, "wrong payment type", "country not available" | Check body and query params |
| 401 | Invalid or missing API key | Check X-Api-Key |
| 403 (HTML) | WAF rate-limit | Retry with exponential backoff |
| 403 (JSON) | "Access right not sufficient" | Wrong key (public vs private) |
| 500 | Internal Bictorys error | Retry later |
4. OTP Flow — Orange Money Côte d'Ivoire
Orange Money CI and BF are the only operators that require a customer-generated OTP code.
Flow Steps
- The user dials
#144*82#on their Orange CI phone - They receive an OTP code (6-8 digits)
- They enter this code in your payment form
- You send the code in the
otpfield of the charge body - Bictorys returns a USSD message
- The user validates on their phone
- Bictorys sends a webhook with the final status
Detection in Code
const needsOtp = paymentType === "orange_money" && (country === "CI" || country === "BF");
Sending in the Body
const body: Record<string, unknown> = {
amount,
currency,
country,
paymentReference,
successRedirectUrl,
ErrorRedirectUrl,
customerObject,
};
// Only for Orange Money CI / BF
if (otp) {
body.otp = otp;
}
UX Tips
- Display a dedicated step for OTP entry, before the payment
- Explain clearly: "Dial #144*82# on your phone to generate your OTP code"
- On failure → bring the user back to the OTP step (not the full form) to facilitate retry
- The OTP expires quickly — the user may need to redial
#144*82#
5. Checking Transaction Status
When to use polling? For POS terminals or applications without a backend. If you have a backend, prefer webhooks (see §6).
Endpoint
GET {BICTORYS_API_URL}/pay/v1/transactions/{transactionId}/status
Headers
| Header | Value |
|---|---|
X-Api-Key | BICTORYS_API_KEY (public key) |
Possible Statuses
| Status | Description | Recommended action |
|---|---|---|
succeeded | Payment confirmed | ✅ Validate the order |
authorized | Payment authorized (card pre-capture) | ✅ Treat as succeeded |
pending | Awaiting customer validation | ⏳ Continue polling |
processing | Being processed | ⏳ Continue polling |
failed | Payment failed | ❌ Mark as FAILED |
cancelled | Cancelled by customer | ❌ Mark as FAILED |
reversed | Refunded/cancelled after success | ❌ Mark as FAILED |
6. Webhooks — Receiving Notifications
Dashboard Configuration
- Dashboard → Developers → Webhooks
- Add your URL:
https://your-api.com/webhooks/bictorys - Enter the Secret Key
- Save
Test vs Production
Webhook configuration is separate between test and production modes. A webhook configured in test is not active in production, and vice versa. Configure both environments.
Headers Sent by Bictorys
POST https://your-api.com/webhooks/bictorys
Content-Type: application/json
X-Secret-Key: <your_webhook_secret> # always present
X-Webhook-Signature: <hmac_sha256_hex> # optional (if HMAC enabled)
X-Webhook-Timestamp: <unix_timestamp_ms> # optional (if HMAC enabled)
Signature Validation
Method 1 — HMAC-SHA256 (recommended)
Use this if X-Webhook-Signature is present.
import crypto from "crypto";
function verifyHmacSignature(
rawBody: string,
secret: string,
signature: string,
timestamp: string
): boolean {
// 1. Replay protection — reject if timestamp > 5 minutes old
const ts = parseInt(timestamp, 10);
if (isNaN(ts) || Math.abs(Date.now() - ts) > 5 * 60 * 1000) {
return false;
}
// 2. Compute the HMAC
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
// 3. Timing-safe comparison
try {
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
} catch {
return false;
}
}
Method 2 — Static key (fallback)
Use this if X-Webhook-Signature is not sent.
function verifyStaticKey(secretKeyHeader: string, expectedSecret: string): boolean {
try {
return crypto.timingSafeEqual(
Buffer.from(secretKeyHeader),
Buffer.from(expectedSecret)
);
} catch {
return false;
}
}
Critical security — Always use
crypto.timingSafeEqual(), never===to compare secrets (timing attack vulnerability).
Webhook Payload
{
"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"
}
Key Fields
Official documentation: https://docs.bictorys.com/docs/how-to-validate-webhooks
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Unique Bictorys transaction ID |
status | string | "succeeded", "failed", "cancelled", "authorized", "reversed" |
paymentReference | string | Your order reference |
amount | integer | Amount in FCFA |
currency | string | "XOF" |
pspName | string | Operator used ("wave_money", "orange_money", etc.) |
merchantFees | integer | Bictorys fees charged to the merchant |
customerFees | integer | Fees charged to the customer |
merchantReference | string | Same value as paymentReference |
timestamp | string (ISO 8601) | Transaction date/time |
Implementation Best Practices
Webhook checklist
- Raw body — Receive the body as a raw Buffer, before the global JSON parser (
express.raw()beforeexpress.json())- Verify signature — HMAC or static key with
timingSafeEqual- Log to database — Before any processing, for debug and audit
- Anti-fraud — Verify that
amount+currencymatch your order- Idempotency — Do not process the same webhook twice (log table + Serializable transaction)
- Always return HTTP 200 — Even on internal error, otherwise Bictorys will retry 3 times
Express.js Implementation
import express from "express";
import crypto from "crypto";
const app = express();
// ⚠️ CRITICAL ORDER: raw BEFORE 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;
// Signature verification
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 anyway
return;
}
const payload = JSON.parse(rawBody);
const { id, status, paymentReference, amount, currency } = payload;
// → Log, verify anti-fraud, process idempotently
// → Your business logic here
res.status(200).json({ received: true });
} catch (error) {
console.error("Webhook error:", error);
res.status(200).json({ received: true }); // ALWAYS 200
}
});
7. Creating a Payout
Payouts allow you to send money to a Mobile Money account.
Endpoint
POST {BICTORYS_API_URL}/pay/v1/payouts?payment_type={type}
Headers
| Header | Value |
|---|---|
X-API-Key | BICTORYS_PRIVATE_KEY (private key — mandatory) |
Content-Type | application/json |
accept | application/json |
idempotency-key | Unique UUID per payout (prevents duplicates) |
The public key returns
401on payouts. Only the private key works.
Payment Types
| Value | Description |
|---|---|
wave_money | Withdraw to Wave |
orange_money | Withdraw to Orange Money |
mtn_money | Withdraw to MTN Money |
moov | Withdraw to 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": "Fund transfer",
"merchantReference": "WD-ABC123",
"merchant": {
"secretCode": "1234"
}
}
Body Parameters
| Field | Type | Required | Description |
|---|---|---|---|
amount | integer | ✅ | Amount in FCFA (integer) |
currency | string | ✅ | "XOF" |
country | string | ✅ | "SN", "CI", "BJ", "ML", "TG", "BF" |
customerObject | object | ✅ | Payout recipient |
customerObject.phone | string | ✅ | Phone in "+221771234567" format |
customerObject.name | string | ✅ | Recipient name |
transactionType | string | ✅ | "payment" |
paymentReason | string | ✅ | Reason for the withdrawal |
merchantReference | string | ✅ | Your unique reference |
merchant.secretCode | string | ✅ | BICTORYS_MERCHANT_SECRET_CODE |
Response — 200 OK or 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"
}
Notes
amountis negative (money leaving the merchant account)status: 0= successmerchantFee= Bictorys fee on the transfer
Common Errors
| HTTP | Body contains | Meaning |
|---|---|---|
| 401 | — | Wrong key (use PRIVATE_KEY, not API_KEY) |
| 400 | "balance" | Insufficient Bictorys balance |
| 400 | "plafon" or "limit" | Recipient's Mobile Money limit reached |
| 400 | "phone" | Invalid phone number |
| 400 | "secretCode" | Incorrect merchant code |
| 500+ | — | Bictorys server error — idempotency-key header missing |
Payout recommendations
- Always send an
idempotency-key(UUID) to prevent duplicate sends- Set a 30-second timeout on the request (payouts can be slow)
- Handle non-JSON responses (the WAF may return HTML)
- Log the raw response for debugging
8. Supported Payment Methods
List Your Activated Operators
GET {BICTORYS_API_URL}/onboarding/v1/payment-methods/me
X-API-Key: BICTORYS_PRIVATE_KEY
Full Catalog (March 2026)
| Internal name | payment_type | Countries | Pay-in | Pay-out | Phone required |
|---|---|---|---|---|---|
wave_money | wave_money | SN | ✅ | ✅ | no |
wave_money_civ | wave_money | CI, BF | ✅ | ✅ | yes |
orange_money_sn | orange_money | SN | ✅ | ✅ | no |
orange_money_civ | orange_money | CI | ✅ | ✅ | yes |
orange_money_ml | orange_money | ML | ✅ | ✅ | yes |
orange_money_bk | orange_money | BK | ✅ | ✅ | yes |
mtn_money | mtn_money | CI, BJ | ✅ | ✅ | yes |
moov | moov | TG, CI, BF, BJ | ✅ | ✅ | yes |
togocell | togocell | TG | ✅ | ✅ | yes |
mobicash | mobicash | BF, ML | ✅ | ✅ | yes |
maxit | maxit | SN | ✅ | ✅ | no |
card | card | SN, CI | ✅ | ✅ | no |
Real Test Results — Sandbox, March 2026
| Operator + Country | Result | Notes |
|---|---|---|
| Wave SN | ✅ | — |
| Wave CI | ✅ | — |
| Orange Money SN | ✅ | — |
| Orange Money CI (OTP) | ✅ | Requires otp in 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" |
Summary
As of March 2026, the operators functional in sandbox are: Wave (SN, CI), Orange Money (SN, CI), MTN Money (CI), and Card (SN, CI, BF).
Other countries/operators return errors.
Card works for all countries because Bictorys normalizes the country internally.
9. Country Normalization
Bictorys Country Codes
| Code | Country | Dial code |
|---|---|---|
SN | Senegal | +221 |
CI | Côte d'Ivoire | +225 |
BK | Burkina Faso | +226 |
ML | Mali | +223 |
TG | Togo | +228 |
BJ | Benin | +229 |
Automatic Detection by Dial Code
+221...→SN(Senegal)+225...→CI(Côte d'Ivoire)+226...→BF(Burkina Faso)+223...→ML(Mali)+228...→TG(Togo)+229...→BJ(Benin)
10. Common Errors & Debugging
❌ WAF 403 — HTML Response Instead of JSON
Cause: AWS WAF rate limit. The response is HTML (<html>... Forbidden ...).
Solution: Retry with exponential backoff. In test, space requests 5 seconds apart.
❌ 403 JSON — "Access right not sufficient"
Cause: Wrong key. Public key used for a payout, or private key from another account.
Solution: Verify the correct key for each operation (see §2).
❌ 400 — "wrong payment type"
Cause: The operator is not activated for this country on your Bictorys account.
Solution: Check via GET /onboarding/v1/payment-methods/me.
❌ 400 — "country not available"
Cause: The country is not activated for this operator on your account.
Solution: Contact Bictorys to activate the country.
❌ Webhook Not Received
Possible causes:
- Webhook not configured (or configured in test but not in production)
- URL not accessible from the Internet
- Secret Key does not match
- Mode (test/production) does not match the API keys used
Solution: Check in the Bictorys Dashboard → Developers → Webhooks that the URL and secret are correct for the right environment.
❌ 500 on GET /charges/{id} in test
Cause: Known Bictorys sandbox behavior.
Solution: Rely on webhooks in the test environment. Status check works in production.
❌ Invalid / Expired OTP (Orange Money CI)
Cause: The OTP code expires quickly.
Solution: The user must redial #144*82# to generate a new code.
❌ Empty 200 Response (no JSON)
Cause: WAF in test mode silently blocking requests that are too rapid.
Solution: Add a 5-second delay between requests in test.
11. Production Checklist
🏢 Bictorys Dashboard
- Production mode activated
- Production API keys obtained (no
test_prefix) - Webhook configured in production mode with correct URL and secret
- Test micro-payment validated (500 FCFA) in production
🔧 Environment Variables
-
BICTORYS_API_URL→https://api.bictorys.com -
BICTORYS_API_KEY→ production public key (prefixpublic-) -
BICTORYS_PRIVATE_KEY→ production private key (prefixsecret-) -
BICTORYS_WEBHOOK_SECRET→ production webhook secret -
BICTORYS_MERCHANT_SECRET_CODE→ merchant code (if payouts)
🔒 Security
- Webhook signature validation (HMAC-SHA256 or static key with
timingSafeEqual) - Webhook anti-fraud: verify amount + currency
- Webhook idempotency: Serializable transaction + log table
- WAF 403 retry: exponential backoff implemented
- Payout:
idempotency-keysent + 30s timeout -
express.raw()beforeexpress.json()for webhooks - Always return 200 on the webhook (even on internal error)
- Private key never exposed client-side
- Polling fallback implemented if webhook does not arrive
12. Complete Code Examples
TypeScript Provider (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!;
// ─── Create a payment ───────────────────────────────────────
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> {
// For cards, add 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, // ⚠️ capital E
customerObject: params.customer,
};
// otp field only for Orange Money CI / BF
if (params.otp) body.otp = params.otp;
const MAX_RETRIES = 3;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
// Exponential backoff on 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");
}
// ─── Check transaction status ────────────────────────────────
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.
}
// ─── Create a 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: "Fund transfer",
merchantReference: params.merchantReference,
merchant: { secretCode: MERCHANT_SECRET_CODE },
};
// 30s timeout (payouts can be slow)
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);
try {
const response = await fetch(url, {
method: "POST",
headers: {
"X-API-Key": PRIVATE_KEY, // ⚠️ private key mandatory
"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;
}
}
// ─── Verify a 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"];
// Method 1: HMAC-SHA256 (recommended)
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;
}
}
// Method 2: Static key (fallback)
if (secretKey) {
try {
return crypto.timingSafeEqual(
Buffer.from(secretKey),
Buffer.from(WEBHOOK_SECRET)
);
} catch {
return false;
}
}
return false;
}
// ─── Detect country from phone number ────────────────────────
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;
}
Last updated: March 2026 — Based on real-world testing with Bictorys API v1, test and production environments.
Updated 1 day ago