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

  1. Configuration & Environment Variables
  2. Authentication
  3. Creating a Payment (Charge)
  4. OTP Flow — Orange Money Côte d'Ivoire
  5. Checking Transaction Status
  6. Webhooks — Receiving Notifications
  7. Creating a Payout
  8. Supported Payment Methods
  9. Country Normalization
  10. Common Errors & Debugging
  11. Production Checklist
  12. Complete Code Examples

1. Configuration & Environment Variables

🔑 Required Keys and Variables

VariableUsageWhere to find it
BICTORYS_API_URLAPI base URLSee table below
BICTORYS_API_KEYPublic key — creating charges and checking statusesDashboard → Developers → API Keys → Public Key
BICTORYS_PRIVATE_KEYPrivate key — payouts and reading payment methodsDashboard → Developers → API Keys → Private Key
BICTORYS_WEBHOOK_SECRETDedicated secret — validating incoming webhooksDashboard → Developers → Webhooks → Secret Key
BICTORYS_MERCHANT_SECRET_CODEMerchant secret code — required in payout request bodyDashboard → Company → Preferences

🌍 URLs by Environment

EnvironmentBase URLKey Prefix
Test (Sandbox)https://api.test.bictorys.comtest_public-..., test_secret-...
Productionhttps://api.bictorys.compublic-..., 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-Key header for creating charges and verifying transactions. This is the main key for incoming payments.
  • BICTORYS_PRIVATE_KEY (private) — X-API-Key header for payouts (withdrawals) and reading activated operators. NEVER expose client-side.
  • BICTORYS_WEBHOOK_SECRET — Dedicated webhook secret, sent by Bictorys in the X-Secret-Key header. 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?

OperationKey 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

HeaderValue
X-Api-KeyBICTORYS_API_KEY (public key)
Content-Typeapplication/json

Payment Types (query parameter payment_type)

ValueDescription
wave_moneyWave payment
orange_moneyOrange Money payment
mtn_moneyMTN Money payment
moovMoov Money payment
togocellTogocell payment
mobicashMobicash payment
maxitMaxit payment (SN)
cardBank 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

FieldTypeRequiredDescription
amountintegerAmount in FCFA (integer, no decimals). Minimum: 100
currencystringAlways "XOF" for CFA franc
countrystringBictorys country code: "SN", "CI", "BK", "ML", "TG", "BJ"
paymentReferencestringYour unique order reference
successRedirectUrlstringRedirect URL after successful payment
ErrorRedirectUrlstringRedirect URL after failure
customerObjectobjectCustomer information (recommended)
customerObject.namestringCustomer name
customerObject.phonestringPhone in "+221771234567" format (no spaces)
customerObject.emailstringCustomer email
customerObject.countrystringCustomer country code
otpstringOTP code — Orange Money CI only (see §4)

⚠️

Phone format

The phone number is not mandatory for wave, orange_money, and maxit payment types in Senegal.

The required format is +COUNTRY_CODE + NUMBER concatenated, 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..."
}
FieldTypeWhen presentUsage
transactionIdstring (UUID)AlwaysUnique Bictorys ID
redirectUrlstringAlwaysRedirect URL (general fallback)
linkstringWave, CardDirect link (Wave deep link or card checkout page)
qrCodestring (base64 PNG)WaveQR code to display for desktop/POS
messagestringOrange CI, MTN CI, Orange SNUSSD message to display to the user

UX Flow by Operator

OperatorRecommended 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 CIDedicated OTP step (#144*82#) → send otp → display message + polling → webhook
Orange Money BFDisplay USSD message + polling → phone validation → webhook
MTN Money CIDisplay message + polling → phone validation → webhook
CardRedirect to link → checkout page → card entry → 3DS → webhook

Error Responses

HTTP StatusCauseAction
400Invalid parameters, "wrong payment type", "country not available"Check body and query params
401Invalid or missing API keyCheck X-Api-Key
403 (HTML)WAF rate-limitRetry with exponential backoff
403 (JSON)"Access right not sufficient"Wrong key (public vs private)
500Internal Bictorys errorRetry 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

  1. The user dials #144*82# on their Orange CI phone
  2. They receive an OTP code (6-8 digits)
  3. They enter this code in your payment form
  4. You send the code in the otp field of the charge body
  5. Bictorys returns a USSD message
  6. The user validates on their phone
  7. 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

HeaderValue
X-Api-KeyBICTORYS_API_KEY (public key)

Possible Statuses

StatusDescriptionRecommended action
succeededPayment confirmed✅ Validate the order
authorizedPayment authorized (card pre-capture)✅ Treat as succeeded
pendingAwaiting customer validation⏳ Continue polling
processingBeing processed⏳ Continue polling
failedPayment failed❌ Mark as FAILED
cancelledCancelled by customer❌ Mark as FAILED
reversedRefunded/cancelled after success❌ Mark as FAILED

6. Webhooks — Receiving Notifications

Dashboard Configuration

  1. Dashboard → Developers → Webhooks
  2. Add your URL: https://your-api.com/webhooks/bictorys
  3. Enter the Secret Key
  4. 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

FieldTypeDescription
idstring (UUID)Unique Bictorys transaction ID
statusstring"succeeded", "failed", "cancelled", "authorized", "reversed"
paymentReferencestringYour order reference
amountintegerAmount in FCFA
currencystring"XOF"
pspNamestringOperator used ("wave_money", "orange_money", etc.)
merchantFeesintegerBictorys fees charged to the merchant
customerFeesintegerFees charged to the customer
merchantReferencestringSame value as paymentReference
timestampstring (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() before express.json())
  • Verify signature — HMAC or static key with timingSafeEqual
  • Log to database — Before any processing, for debug and audit
  • Anti-fraud — Verify that amount + currency match 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

HeaderValue
X-API-KeyBICTORYS_PRIVATE_KEY (private key — mandatory)
Content-Typeapplication/json
acceptapplication/json
idempotency-keyUnique UUID per payout (prevents duplicates)

🔒

The public key returns 401 on payouts. Only the private key works.

Payment Types

ValueDescription
wave_moneyWithdraw to Wave
orange_moneyWithdraw to Orange Money
mtn_moneyWithdraw to MTN Money
moovWithdraw 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

FieldTypeRequiredDescription
amountintegerAmount in FCFA (integer)
currencystring"XOF"
countrystring"SN", "CI", "BJ", "ML", "TG", "BF"
customerObjectobjectPayout recipient
customerObject.phonestringPhone in "+221771234567" format
customerObject.namestringRecipient name
transactionTypestring"payment"
paymentReasonstringReason for the withdrawal
merchantReferencestringYour unique reference
merchant.secretCodestringBICTORYS_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

  • amount is negative (money leaving the merchant account)
  • status: 0 = success
  • merchantFee = Bictorys fee on the transfer

Common Errors

HTTPBody containsMeaning
401Wrong 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 namepayment_typeCountriesPay-inPay-outPhone required
wave_moneywave_moneySNno
wave_money_civwave_moneyCI, BFyes
orange_money_snorange_moneySNno
orange_money_civorange_moneyCIyes
orange_money_mlorange_moneyMLyes
orange_money_bkorange_moneyBKyes
mtn_moneymtn_moneyCI, BJyes
moovmoovTG, CI, BF, BJyes
togocelltogocellTGyes
mobicashmobicashBF, MLyes
maxitmaxitSNno
cardcardSN, CIno

Real Test Results — Sandbox, March 2026

Operator + CountryResultNotes
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

CodeCountryDial code
SNSenegal+221
CICôte d'Ivoire+225
BKBurkina Faso+226
MLMali+223
TGTogo+228
BJBenin+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_URLhttps://api.bictorys.com
  • BICTORYS_API_KEY → production public key (prefix public-)
  • BICTORYS_PRIVATE_KEY → production private key (prefix secret-)
  • 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-key sent + 30s timeout
  • express.raw() before express.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.


Did this page help you?