Skip to content
All providers

Bictorys

payment·🇸🇳🇨🇮🇲🇱🇹🇬🇧🇯🇧🇫·Sandbox available

Bictorys is a payment infrastructure provider serving West and Central Africa, supporting mobile money and card payments across multiple countries through a unified API.

Use with AI agents

After installing the plugin or adding the MCP server, prompt your agent:

“Initiate a mobile money or card payment and redirect the customer to complete checkout.”
Install the plugin →

Capabilities

CapabilityTypeStatusiMethodExample
create_chargesynchronousAvailablePOST
create_payoutsynchronousAvailablePOST
verify_transactionsynchronousAvailableGET
webhook_payment_completedwebhookAvailablePOST

Gotchas

create_charge

  • ⚠payment_type is a query parameter, not a request body field. Append it to the URL: POST /pay/v1/charges?payment_type=wave_money. Omitting it returns HTTP 202 with a hosted-checkout link instead of a direct 201 response.
  • ⚠The response HTTP status determines the flow: 201 means the direct payment was initiated (use response.transactionId and response.redirectUrl); 202 means the customer must be sent to response.link on the Bictorys-hosted checkout page.
  • ⚠Store transactionId (201 flow) or chargeId (202 flow) before redirecting the customer — you'll need it to match the webhook_payment_completed event back to this order, and optionally to call verify_transaction if you need an immediate status for the redirect UX.
  • ⚠Nothing is fulfillable right after create_charge returns — the customer hasn't paid yet, they're only about to be redirected to complete payment. Never trust the browser redirect alone to mark an order paid (it's fully spoofable — a user can visit successRedirectUrl without paying). The source of truth for fulfillment is the signature-verified webhook_payment_completed event, not this response and not the redirect.
  • ⚠successRedirectUrl and errorRedirectUrl must start with https:// — HTTP URLs are rejected with E400-0: redirectUrl must start with https://.
  • ⚠Sandbox base URL is https://api.test.bictorys.com — the production base URL is https://api.bictorys.com. Sandbox and production use separate API keys obtained from the Bictorys dashboard.
  • ⚠Card payments via the direct API (payment_type=card with a cardObject in the body) require PCI-DSS certification. Mobile money payments have no certification requirement.
  • ⚠Card payments return E401-7: Card payment is not activated yet in production until Bictorys manually activates card payments for your merchant account — reproduces both with an explicit payment_type=card and via the hosted-checkout flow (no payment_type, customer picks card from Bictorys' page). Sandbox does not reproduce this — test cards work there with no activation step, so this will not surface until you go live. Contact Bictorys support to enable card payments on your production account before launch; mobile money has no such gate.
  • ⚠create_charge uses the PUBLIC API key (env BICTORYS_API_KEY, header X-Api-Key). verify_transaction and create_payout both use the PRIVATE key (env BICTORYS_PRIVATE_KEY, header X-API-Key) — despite Bictorys' documentation claiming verify_transaction needs the public key, a real sandbox call with the private key returns 200 successfully.
  • ⚠Orange Money Côte d'Ivoire requires an OTP: the customer must dial #144*82# on their phone to generate a 6-8 digit code, then pass it as the 'otp' field in the request body. The OTP expires quickly — if the charge fails with an OTP error, ask the user to redial.
  • ⚠The request body field is 'errorRedirectUrl' (lowercase e) — Bictorys' own documentation example shows 'ErrorRedirectUrl' (capital E), which appears to be a doc typo; lowercase is confirmed working end-to-end in production (charge creation + successful webhook).
  • ⚠HTTP 403 with an HTML body (not JSON) means AWS WAF is rate-limiting your requests. Implement exponential backoff with at least 5-second spacing between retries.

create_payout

  • ⚠payment_type is a required query parameter. Append it to the URL: POST /pay/v1/payouts?payment_type=wave_money. Supported values: wave_money, orange_money, mtn_money, moov.
  • ⚠transactionType is required — omitting it causes a 500. Use "payment" for standard disbursements.
  • ⚠idempotency-key header is effectively required — a missing idempotency-key is a known cause of 500 errors. Always send a UUID per request.
  • ⚠merchant.secretCode is the operator-issued merchant PIN (e.g. your Orange Money or MTN merchant code) registered in Bictorys Dashboard → Entreprise → Préférences. Required when configured on your account — omitting it returns E400-37: Secret code is missing.
  • ⚠customerObject.phone is a string with a + and country code prefix, no spaces. Example: "+221771234567" for Senegal. Using a numeric type causes a 400 phone error.
  • ⚠The response amount is a negative integer — money leaving the merchant account. Use Math.abs() if displaying to the user.
  • ⚠The response status field is an integer (0 = success), not a string like other Bictorys endpoints.
  • ⚠Money transfer must be explicitly enabled for your merchant account. If not, Bictorys returns E400-28: Money transfer is not authorized for this merchant. Contact Bictorys support.
  • ⚠A 400 error mentioning "balance" means Bictorys' own float is insufficient; a 400 error mentioning "plafond" or "limit" means the recipient's mobile money account hit its own operator-side ceiling — these are distinct failure modes and neither is retryable without merchant/recipient action.
  • ⚠Set your HTTP client timeout to at least 30 seconds — payout processing is slower than charge creation.
  • ⚠Sandbox base URL is https://api.test.bictorys.com — the production URL is https://api.bictorys.com. Sandbox payouts do not send real money.

verify_transaction

  • ⚠Redirect query parameters are fully spoofable — never fulfill an order from them alone; call this endpoint server-side (or wait for the webhook) before trusting a redirect. A signature-verified webhook_payment_completed event, on the other hand, does not need this endpoint to be trusted — its HMAC/secret verification already proves authenticity, and gating fulfillment on a live call here risks losing an already-successful payment if this endpoint is briefly down (see the 500 gotcha below).
  • ⚠Use the PRIVATE key here (env BICTORYS_PRIVATE_KEY, same key as create_payout, header X-API-Key). Note that the sandbox currently accepts the PUBLIC key on this endpoint as well — measured 2026-09-14, both keys return 200 — so a sandbox-only test will not catch a wrong key. Do not rely on that permissiveness in production.
  • ⚠Only status === "succeeded" means the payment is fully complete and funds are captured. "authorized" means funds are reserved but not yet captured. "pending" and "processing" mean the payment is still in progress.
  • ⚠The transactionId to pass here is the id field from the webhook payload, or the transactionId / chargeId field from the create_charge response — they refer to the same transaction UUID.
  • ⚠Sandbox base URL is https://api.test.bictorys.com — the production URL is https://api.bictorys.com.
  • ⚠On status 'succeeded', this endpoint returns NO amount and NO timestamp — observed live: {"id": "…", "status": "succeeded"} and nothing else. Reading status and amount from the same response therefore yields 0 for the amount ON THE SUCCESS PATH, which in a billing system means crediting nothing while everything reports success. Fall back to GET /pay/v1/transactions and match on paymentReference to obtain amount and timestamp.
  • ⚠The list endpoint GET /pay/v1/transactions (undocumented) only becomes non-empty once a transaction COMPLETES. It stays [] while a charge is merely pending, which makes reference-based lookup look untestable in sandbox until the first successful payment.
  • ⚠In the Bictorys sandbox, GET /pay/v1/transactions/{id}/status is known to return HTTP 500 intermittently. For sandbox testing, rely on webhook events instead of polling this endpoint.
  • ⚠Bictorys recommends polling this endpoint for backend-less apps (e.g. static/mobile-only clients that can't expose a webhook endpoint). Apps with a backend server should treat webhook_payment_completed as the source of truth for fulfillment, and use this endpoint for the redirect-triggered UX check and for reconciliation (e.g. a periodic sweep of orders whose webhook never arrived) — not as a mandatory gate after every webhook.

webhook_payment_completed

  • ⚠Bictorys sends two validation headers: X-Secret-Key (raw shared secret, always present) and optionally X-Webhook-Signature (HMAC-SHA256 hex) + X-Webhook-Timestamp (Unix milliseconds). When X-Webhook-Signature is present, verify with HMAC-SHA256(BICTORYS_WEBHOOK_SECRET, '${timestamp}.${rawBody}') and reject payloads where Math.abs(Date.now() - timestamp) > 300_000.
  • ⚠The HMAC pair is ACCOUNT-GATED and off by default: docs.bictorys.com/docs/intégration-en marks both headers 'optional (if HMAC enabled)'. A live sandbox webhook captured 2026-09-14 carried only accept, content-length, content-type, host, user-agent and x-secret-key — no signature, no timestamp. Write both paths, but do not assume the HMAC will be there: on an account where it is not enabled, your fallback is what runs in production.
  • ⚠X-Secret-Key carries the secret in CLEAR on every delivery. Never point a Bictorys webhook at a public capture bin (webhook.site, requestbin) using your real secret — anyone holding the bin URL can read it. Use a throwaway secret for capture testing, then rotate.
  • ⚠WHEN THE HMAC IS NOT ENABLED on your account, X-Secret-Key proves only that the sender knows the secret — it says nothing about the body, and a tampered payload passes (measured: an amount rewritten from 100 to 100000 is accepted). In that configuration the amount/currency/status/paymentReference cross-check below is not a belt-and-braces extra: it is the only thing standing between you and a forged payload.
  • ⚠Always use crypto.timingSafeEqual() when comparing X-Secret-Key or HMAC values — never use === or ==. String equality comparison is vulnerable to timing attacks.
  • ⚠Bictorys retries the webhook up to 3 times if your endpoint returns a non-2xx status. Always respond HTTP 200 immediately, even if your internal processing fails — then handle errors asynchronously.
  • ⚠Express users: mount express.raw({ type: 'application/json' }) before express.json() on the webhook route, or keep req.rawBody. The raw body is required to compute the HMAC if it is enabled on your account — JSON.parse() loses it — and it is what you log for audit either way.
  • ⚠Don't gate fulfillment on an extra synchronous call to verify_transaction — a signature-verified webhook (HMAC or X-Secret-Key) combined with the anti-fraud amount/currency match below is already sufficient proof. verify_transaction's /status endpoint is known to be intermittently unavailable (see its own gotchas), so calling it here risks losing an already-successful payment to a transient outage on Bictorys' side. Reserve verify_transaction for the client-side redirect flow (which IS spoofable and needs it) and for reconciliation if a webhook never arrives.
  • ⚠Anti-fraud check: before fulfilling, verify that the webhook's amount and currency match your own order records — a forged or replayed payload could otherwise pass signature checks with tampered amounts.
  • ⚠Log the raw webhook payload to a persistent store (DB table) immediately after signature verification and before any business logic runs — this gives you an audit trail even if downstream processing fails, and is the natural place to implement the idempotency check above.
  • ⚠Implement idempotency using the transaction id field. Bictorys may deliver the same webhook event more than once; processing the same id twice must not double-fulfill an order.
  • ⚠Your webhook URL must be publicly reachable over HTTPS. Webhook configuration is separate for test vs. production environments in the Bictorys dashboard.
  • ⚠Bictorys recommends this webhook for apps with a backend server. For backend-less apps (e.g. static/mobile-only clients that can't expose a webhook endpoint), poll verify_transaction instead to check payment status.
  • ⚠The timestamp field is not strict ISO 8601 (space instead of 'T', no timezone, non-standard fractional-second precision — e.g. "2026-08-08 18:45:44.10254"). Don't rely on a strict ISO parser; use a lenient date library or normalize the string first.

Details

Category
payment
Sandbox
Yes