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
| Capability | Type | StatusiVerified — tested in a real application.Available — spec complete, usable by agents. | Method | Example |
|---|---|---|---|---|
| create_charge | synchronous | Available | POST | Example |
| create_payout | synchronous | Available | POST | Example |
| verify_transaction | synchronous | Available | GET | Example |
| webhook_payment_completed | webhook | Available | POST | Example |
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.
- ⚠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).
- ⚠This endpoint requires the PRIVATE key (env BICTORYS_PRIVATE_KEY, same key as create_payout, header X-API-Key), not the public key used for create_charge. This contradicts Bictorys' own documentation, which claims the public key is required — confirmed against a real sandbox call returning 200 with the private key on 2026-08-08.
- ⚠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.
- ⚠The /status endpoint is known to return a minimal response in sandbox — observed live: {"id": "...", "status": "succeeded"} with no other fields, even for a fully completed transaction. Don't assume pspName, amount, timestamp, or customer info will be present; treat everything beyond id/status as optional in sandbox. Production may return the full object.
- ⚠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.
- ⚠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. The raw string body is required to compute the HMAC; JSON.parse() loses it.
- ⚠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