B-Cashby Bridge Docs
Documentation

Collect Mobile Money payments without guessing what happened

Connected payments, simple and reliable.

B-Cash exposes a REST API to collect and pay out in CFA francs through Cameroonian Mobile Money operators, a console to run your business, and a HUB that actually executes the operations. This documentation covers each of those nodes.

Merchant API

What your code calls

Collections, payouts, fee quotes, balances, refunds, webhooks. Signed authentication, whole-XAF amounts, mandatory idempotency on every money-moving write.

Start with authentication →
Merchant console

What your team opens

Organisations, roles, activation file, projects and keys, webhook delivery log, settlements and audit trail.

Explore the console →
Orchestration

What relays to the operators

The layer that routes each operation to the relevant operator, follows its outcome and reports it back to the ledger. You observe statuses, not mechanisms.

What this means for you →
Integrations

What you plug in

WooCommerce extension, mobile apps through ephemeral tokens, or direct integration from your own backend.

Choose your integration →

Hosts

RoleHostUsed for
APIapi.gba-cm.onlineAll /api/v1/* and /api/dashboard/* calls
Merchant consoledashboard.gba-cm.onlineMerchant interface and back office
Documentationdocs.gba-cm.onlineThis page

Reading conventions

Getting started

System architecture

Five nodes, one path for the money. Knowing who talks to whom answers half of all support questions.

Your system calls the B-Cash API, which routes the operation to Mobile Money operators through its orchestration layer, then notifies your endpoint by webhook. The console reads the same API. Your system store · app · backend B-Cash API ledger · fees · idempotency wallets · webhooks Orchestration routing and outcome tracking Operators MTN MoMo · Orange Money Merchant console team · file · keys Your endpoint signed webhooks signed HTTPS signed event

Who does what

NodeResponsibilityWho authenticates
Merchant API
/api/v1/*
Records the payment intent, computes fees, keeps the balance ledger, guarantees idempotency, emits events. Your servers (HMAC key) and your mobile clients (ephemeral token)
Console
/api/dashboard/*
Human read and control: transactions, balances, team, activation file, keys, audit. Your users (session JWT)
Orchestration Routes the operation to the operator, follows its outcome and reports it to the ledger. Internal — no merchant integration
Webhooks Pushes state changes to your systems, signed and automatically retried. B-Cash to you (HMAC signature you must verify)
Operators Actually debit or credit the customer's Mobile Money account. Outside B-Cash scope
The thing to remember

Your code never talks to the operators. It creates a transaction, then waits: either by polling the transaction, or — preferably — by receiving a webhook.

Getting started

Five-minute start

From an empty account to your first successful test collection.

1. Create an account and get your test keys

Open an account on the console. A sandbox project and its credentials are generated immediately. The secret is shown once: copy it before closing the panel.

2. Check that the key works

GET /api/v1/me
# Authentication probe: consumes nothing, creates nothing
curl https://api.gba-cm.online/api/v1/me \
  -H "X-BCash-Key: $BCASH_KEY" \
  -H "X-BCash-Timestamp: $(date +%s)" \
  -H "X-BCash-Signature: $SIGNATURE"

How to compute $SIGNATURE is covered in Authentication. A 200 confirms that key, secret and clock are all correct.

3. Register a notification endpoint

POST /api/v1/webhook-endpoints
{
  "url": "https://store.example.cm/webhooks/bcash",
  "events": ["transaction.succeeded", "transaction.failed"]
}

# → 201 { "reference": "whk_…", "secret": "whsec_…" }   ← the secret appears only here

4. Create a test collection

POST /api/v1/collections
curl -X POST https://api.gba-cm.online/api/v1/collections \
  -H "X-BCash-Key: $BCASH_KEY" \
  -H "X-BCash-Timestamp: $(date +%s)" \
  -H "X-BCash-Signature: $SIGNATURE" \
  -H "Idempotency-Key: 8f1c2b4e-3a77-4c11-9f2d-6b0a5e91c7d3" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 25000,
    "operator": "MTN",
    "customer": { "phone": "+237650000000" },
    "merchantReference": "ORD-1042",
    "context": "API_DIRECT"
  }'

5. Force the outcome in sandbox

In testing, nobody is going to type a PIN: trigger the outcome yourself.

POST /api/v1/sandbox/transactions/{reference}/advance
{ "target": "SUCCEEDED" }

# The transaction follows the nominal path: ledger entries,
# a signed transaction.succeeded webhook, and an audit trail.
You're done

Your endpoint just received a signed event. What remains is verifying its signature (see how) and marking your order as paid.

Getting started

Environments & keys

Two environments, one API, separate keys that never cross.

SandboxProduction
AvailableFrom sign-upOnce your file is approved
MoneyNo real movementReal debits and credits
Transaction outcomeYou force it (/sandbox/…/advance)The customer confirms on their phone
WebhooksDelivered, signed, replayableIdentical
livemode fieldfalsetrue

Anatomy of a credential

A lost secret is gone

It cannot be shown again. Generate a new one: by default the old key stays active while you deploy, and you revoke it once the switch is complete.

Rotating without downtime

  1. From the console, generate a new key in no-downtime mode.
  2. Deploy the new key/secret pair to your servers.
  3. Check in the console that the new key is recording calls (the last used column).
  4. Revoke the old one.

Immediate revocation also exists, for a compromised secret: it kills the old key at once, and any integration still using it receives INVALID_SIGNATURE. The console asks you to retype the project name before applying it.

Fundamentals

Authentication

Three ways to authenticate, for three kinds of caller. Picking the right one is the first architectural decision of any integration.

MethodForHeaders
HMAC keyYour servers, and only them X-BCash-Key, X-BCash-Timestamp, X-BCash-Signature
Ephemeral tokenMobile app or web page Authorization: Bearer bt_…
Session JWTConsole users Authorization: Bearer …
An HMAC key never ships to a client

A mobile app can be decompiled and a web page can be read. Any key embedded there is public on the day of the first install. Clients use the ephemeral token, minted by your backend.

Signing an HMAC request

The string to sign concatenates four elements separated by newlines:

String to sign
METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + sha256_hex(body)

# METHOD    : uppercase HTTP verb, e.g. POST
# PATH      : path only, e.g. /api/v1/collections (no host, no query string)
# TIMESTAMP : Unix seconds, identical to the X-BCash-Timestamp header
# body      : the exact JSON body sent; empty string on GET
PHP
$body      = json_encode($payload, JSON_UNESCAPED_SLASHES);
$timestamp = (string) time();
$toSign    = "POST\n/api/v1/collections\n{$timestamp}\n" . hash('sha256', $body);
$signature = hash_hmac('sha256', $toSign, $secret);

// Send exactly that $body: re-serialising after signing invalidates the request.
Node.js
const body = JSON.stringify(payload);
const ts = Math.floor(Date.now() / 1000).toString();
const digest = crypto.createHash('sha256').update(body).digest('hex');
const signature = crypto
  .createHmac('sha256', secret)
  .update(`POST\n/api/v1/collections\n${ts}\n${digest}`)
  .digest('hex');
The clock matters

A timestamp too far from server time makes the signature fail. Keep your machines synchronised (NTP). If your calls suddenly fail with no code change, check system time before anything else.

Ephemeral tokens for clients

Your backend exchanges its HMAC key for a short-lived token and hands it to the client. The token carries a strict subset of the key's scopes and lives at most 900 seconds.

POST/api/v1/auth/tokenHMAC only
Request and response
// Request, HMAC-signed from YOUR server
{ "scopes": ["collections:create", "transactions:read"], "ttl": 600 }

// Response
{ "token": "bt_9f2c…", "expiresIn": 600, "scopes": ["collections:create", "transactions:read"] }
Fundamentals

Idempotency

Mobile networks drop. This is the rule that keeps a dropped connection from charging your customer twice.

Every route that moves money requires the Idempotency-Key header, a UUID that you generate and keep for the duration of the operation.

SituationAPI response
First request with this keyThe operation runs and the response is stored
Same key, same bodyThe stored response is returned — no second operation
Same key, different body409 IDEMPOTENCY_CONFLICT
Header missing400 — the request is rejected

Choosing your key

Tie the key to the business intent, not to the technical attempt. An order that is paid once deserves a stable key; generating a fresh one on every click re-enables exactly the double charge idempotency was meant to prevent.

Good habit
// Once, when the order is created:
order.paymentIdempotencyKey ??= crypto.randomUUID();
// Every payment attempt for THIS order reuses that key.

Recovering a transaction after a drop

If you lose the response, don't recreate anything: look it up.

GET/api/v1/transactions/by-idempotency-key/{key}
GET/api/v1/transactions/by-merchant-reference/{reference}

Both routes exist for precisely this situation. The second assumes you set merchantReference at creation — always do.

Fundamentals

Amounts & currency

The CFA franc has no sub-unit. That simplicity is a trap for anyone coming from a cents-based API.

Fundamentals

Errors & correlation ID

Every error shares the same envelope, and each one carries what support needs to find it.

Error envelope
{
  "error": {
    "code": "INSUFFICIENT_FUNDS",           // stable machine code
    "message": "Insufficient balance.",     // human text, may be reworded
    "correlationId": "c7f3a91b4e2d",        // quote this to support
    "retryable": false,                     // would retrying help?
    "retryAfter": null,                     // suggested delay, in seconds
    "details": {}                             // context, depending on the code
  }
}

How to react

HTTP codes

StatusMeaningWhat to do
400Malformed requestFix the body or headers
401Invalid signature or tokenCheck key, secret, clock
403Missing scope, or forbidden environmentCheck the credential's scopes
404Resource absent from your scopeCheck the reference
409State or idempotency conflictRead code: the current state forbids the action
422Well-formed but business-invalidRead details
429Too many requestsWait for retryAfter
500Incident on the B-Cash sideRetry with the same key, then report the correlationId
503Temporarily unavailableRetry with increasing back-off

The exhaustive catalogue of machine codes is published by the API itself: GET /api/v1/metaerrors.catalog. Consume it rather than copying a list that will age.

Fundamentals

Pagination & filters

Collections are paginated server-side. Never fetch everything to filter in memory.

GET/api/v1/transactions?page=1&itemsPerPage=25
ParameterPurpose
pagePage number, starting at 1
itemsPerPagePage size; capped server-side
statusFilter on a status from the stable list
merchantReferenceYour own reference

The response carries the total; don't recompute it from the number of items received.

Collect & pay out

Operators

The list is dynamic: it carries each operator's service state.

GET/api/v1/operatorspublic
StateMeaningExpected UI behaviour
ACTIVENormal serviceOffer the operator
DEGRADEDSlowness or abnormal failure rateOffer it, warning about possible delays
DOWNUnavailableHide or grey out, with no redeploy
Why not hard-code the list

The operator field is mandatory at creation. If you freeze “MTN and Orange” into your code, an operator outage becomes a release on your side. Reading this route turns it into a display change.

Collect & pay out

Fees & quotes

Two different questions hide behind “how much does it cost”. The API asks which one you are asking.

POST/api/v1/fees/quoteno financial effect

Choosing your basis

amountBasisWhat amount representsWho absorbs the fees
GROSS (default)The amount debited from the customer You. merchantNetAmount = amount − fees
ORDER_NETThe net you want to receive The customer. customerPayableAmount = amount + fees, computed exactly
Quote — customer pays the fees on top
// Request
{
  "type": "COLLECTION",
  "context": "WOOCOMMERCE",
  "amount": 25000,
  "amountBasis": "ORDER_NET",
  "operator": "MTN"
}

// Response
{
  "reference": "fq_7Q4K…",
  "orderAmount": 25000,
  "customerFeeAmount": 500,
  "customerPayableAmount": 25500,   // ← use this as "amount" at creation
  "merchantNetAmount": 25000,
  "feePayer": "CUSTOMER",
  "ruleVersion": "2026-07-01",
  "expiresAt": "2026-07-27T10:15:00Z"   // 900 s
}

Consuming a quote

To guarantee the customer the price you displayed, pass feeQuoteId at creation — and amount must then equal customerPayableAmount exactly. Any other value is rejected: that is the protection against a screen showing one price while the API debits another.

Collect & pay out

Collections

The route to use nine times out of ten: it fixes the type and resolves your destination wallet on its own.

POST/api/v1/collectionsscope collections:create

Request body

FieldRequiredDescription
amountyesWhole XAF. With feeQuoteId, must equal customerPayableAmount.
operatoryesCode from GET /v1/operators.
customer.phoneyesInternational format, e.g. +237650000000.
customer.displayNamenoName shown in tracking views.
merchantReferencenoYour own reference — set it, it will save you one day.
feeQuoteIdnoQuote to consume.
contextnoCall origin: WOOCOMMERCE, MOBILE_APP, API_DIRECT
metadatanoFlat object, max 32 keys, max 4,096 bytes. The bcash_ prefix is reserved.
callbackUrlDeprecated. Accepted, but never used for delivery.
Metadata is visible

It appears in the console and in webhook payloads. Put no ID document, no password, no banking data in it: it is a context field, not a vault.

Response

201 Created
{
  "reference": "TRX7QK4M2",
  "type": "COLLECTION",
  "status": "PENDING_CONFIRMATION",
  "amount": 25500,
  "feeAmount": 500,
  "netAmount": 25000,
  "merchantReference": "ORD-1042",
  "nextAction": {
    "type": "AWAIT_MOBILE_CONFIRMATION",
    "displayMessage": "Confirm the payment on your phone.",
    "expiresAt": "2026-07-27T10:05:00Z"
  },
  "createdAt": "2026-07-27T10:00:00Z"
}

Using nextAction

This block tells you what to display while waiting, so you don't have to invent one message per operator:

Don't invent your own timeout

expiresAt is authoritative. A screen that gives up before that date shows a failure for a transaction that is about to succeed — and your customer pays twice.

Collect & pay out

Payouts

Sending money from your balance to a Mobile Money number.

POST/api/v1/payoutsscope payouts:create
Request
{
  "amount": 50000,                       // what the recipient RECEIVES
  "operator": "ORANGE",
  "recipient": { "phone": "+237690000000", "name": "ALBA Supplies" },
  "merchantReference": "INV-2026-118"
}
Your wallet is debited by more than amount

amount is what reaches the recipient; fees are added to the debit. Request a quote first if you need to display the total cost to a user.

A payout fails if the available balance is insufficient: the required amount is reserved at creation, then released if the operation fails.

Collect & pay out

Transaction lifecycle

Twelve statuses, four terminal ones. A transaction is never “maybe paid”.

CREATEDFEE_QUOTEDHELDQUEUED PROCESSINGPENDING_CONFIRMATIONSUCCEEDED
StatusMeaningTerminal
CREATEDThe transaction is recorded, nothing is committed.no
FEE_QUOTEDFees are fixed for this transaction.no
HELDThe required funds are reserved on the source wallet.no
QUEUEDQueued for execution. Waiting for a worker or a device.no
PROCESSINGExecuting at the operator. No longer cancellable.no
PENDING_CONFIRMATIONThe customer must enter their PIN.no
SUCCEEDEDThe money moved. Fulfil the order.yes
FAILEDRefusal, insufficient balance, wrong PIN… Nothing moved.yes
CANCELLEDCancelled before execution. Reserved funds released.yes
EXPIREDThe customer did not confirm in time.yes
REVERSEDFully refunded.yes, after SUCCEEDED
PENDING_RECONCILIATIONIndeterminate outcome: neither success nor failure. Under reconciliation.no
Never treat PENDING_RECONCILIATION as a failure

This status means exactly: “the money may have moved, we are checking”. No fulfilment, no refund, no retry until the transaction reaches a terminal status. Treating it as a failure means risking a double fulfilment, or refunding a payment that never happened. Resolution is described in When the outcome is not confirmed.

Tracking a transaction

GET/api/v1/transactions/{reference}

Two strategies, and one is clearly better:

Collect & pay out

Cancel & refund

Cancelling

POST/api/v1/transactions/{reference}/cancel

Possible only before execution: CREATED, FEE_QUOTED, HELD, QUEUED. A transaction in PROCESSING cannot be cancelled — the money may already be moving. The call releases reserved funds and is idempotent: cancelling an already-cancelled transaction returns 200.

Refunding

POST/api/v1/transactions/{reference}/refundscope refunds:create
Partial refund
POST /api/v1/transactions/TRX7QK4M2/refund
Idempotency-Key: 3d9a1c77-2f40-4b8e-9c15-77e0a2b4d611

{ "amount": 5000, "reason": "Missing item" }
Collect & pay out

Balances & wallets

A balance is not a single number: three compartments coexist.

GET/api/v1/wallets/{reference}
CompartmentWhat it holds
availableUsable right now: payouts, settlements.
reservedLocked by operations in flight. Neither available nor lost.
pendingCollections whose outcome is not yet consolidated.

The merchant wallet is created automatically at the first collection. A wallet can be frozen by compliance: it remains readable, but movements are blocked. Freezing a wallet and suspending an organisation are two distinct mechanisms.

Notifications

Webhooks: principles

You don't wait on the API: it calls you when state changes.

Payload delivered to your endpoint
{
  "id": "evt_9K2mQ7…",              // stable across attempts → deduplication key
  "type": "transaction.succeeded",
  "apiVersion": "2026-07-20",
  "createdAt": "2026-07-27T10:02:13Z",
  "livemode": true,                  // false in sandbox
  "data": {
    "object": { /* the full transaction */ },
    "previousAttributes": { "status": "PENDING_CONFIRMATION" }
  }
}

The four rules

  1. Verify the signature before reading the body. An unverified endpoint is an open door: anyone can post “payment succeeded”.
  2. Answer 2xx quickly, within a few seconds. Acknowledge, then process in the background. Slow processing triggers pointless retries.
  3. Deduplicate on id. The same evt_… can arrive twice; your handler must be a no-op the second time.
  4. Don't assume ordering. A transaction.succeeded may arrive before a transaction.pending_confirmation. Trust the status carried by the object, not the arrival order.
Notifications

Verifying the signature

The one non-negotiable step of the integration.

HeaderContent
X-BCash-TimestampUnix seconds at emission
X-BCash-SignatureHMAC_SHA256(secret, timestamp + "\n" + raw_body)
X-BCash-Signature-PreviousPresent for 24 h after a rotation: signature with the old secret
Node.js / Express
// The RAW body is essential: re-serialised JSON does not yield the same bytes.
app.post('/webhooks/bcash', express.raw({ type: 'application/json' }), (req, res) => {
  const ts = req.get('X-BCash-Timestamp');
  const raw = req.body.toString('utf8');

  // Replay window: reject an event that is too old.
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);

  const expected = crypto.createHmac('sha256', process.env.BCASH_WEBHOOK_SECRET)
    .update(`${ts}\n${raw}`).digest('hex');

  const candidates = [req.get('X-BCash-Signature'), req.get('X-BCash-Signature-Previous')].filter(Boolean);
  const valid = candidates.some(sig =>
    sig.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)));   // constant-time comparison

  if (!valid) return res.sendStatus(401);

  res.sendStatus(200);              // acknowledge BEFORE processing
  queue.push(JSON.parse(raw));       // async handling, deduplicated on event.id
});
PHP
$raw = file_get_contents('php://input');
$ts  = $_SERVER['HTTP_X_BCASH_TIMESTAMP'] ?? '';

if (abs(time() - (int) $ts) > 300) { http_response_code(400); exit; }

$expected = hash_hmac('sha256', "{$ts}\n{$raw}", $secret);
$ok = hash_equals($expected, $_SERVER['HTTP_X_BCASH_SIGNATURE'] ?? '')
   || hash_equals($expected, $_SERVER['HTTP_X_BCASH_SIGNATURE_PREVIOUS'] ?? '');

if (!$ok) { http_response_code(401); exit; }
Three classic mistakes

Using the already-parsed body instead of raw bytes; comparing signatures with == instead of a constant-time comparison; ignoring X-BCash-Signature-Previous and breaking deliveries during the 24-hour rotation window.

Notifications

Managing endpoints

GET/api/v1/webhook-endpoints
POST/api/v1/webhook-endpointsHTTPS required · scope webhooks:write
PATCH/api/v1/webhook-endpoints/{reference}
DELETE/api/v1/webhook-endpoints/{reference}
POST/api/v1/webhook-endpoints/{reference}/testemits a real webhook.test
POST/api/v1/webhook-endpoints/{reference}/rotate-secret24 h overlap
POST/api/v1/webhook-endpoints/{reference}/enable
POST/api/v1/webhook-endpoints/{reference}/disable

An endpoint reference looks like whk_… and its secret like whsec_…. The secret is shown only at creation and rotation; afterwards the object returns only secretLast4.

Rotating the secret

The new secret signs immediately in X-BCash-Signature, while the old one co-signs in X-BCash-Signature-Previous for 24 hours. If your verification accepts both headers, rotation loses no delivery. The end of the overlap is readable in previousSecretValidUntil.

Filtering events

An empty events array means “all”. Restricting is healthier: your endpoint only receives what it knows how to handle, and events added later won't surprise it.

Notifications

Delivery, retries, duplicates

SituationBehaviour
You answer 2xxDelivery succeeded, done.
You answer something else, or nothingFurther attempts, at increasing intervals.
Repeated failuresThe endpoint moves to FAILING; the console alerts you.
Endpoint unreachable for a long timeIt may be disabled; re-enable it once fixed.

Log and replay

The console keeps every attempt: HTTP code, response time, returned body. A delivery can be replayed manually.

GET/api/dashboard/webhook-deliveries
POST/api/dashboard/webhook-deliveries/{eventId}/replay
Replaying an event does not replay the payment

A replay re-sends a notification, never a money movement. However, if your handler is not deduplicated on id, you can ship the same order twice. Deduplication is your responsibility.

Reference policy

The exact parameters (attempt count, intervals, tolerance window) are published by the API: GET /api/v1/metawebhookDelivery. Read them rather than assuming them; they can be tuned without a major version change.

Testing

Sandbox

Same code, same responses, same webhooks — with no money at stake.

POST/api/v1/sandbox/transactions/{reference}/advancesandbox credentials only

This route forces the outcome of a non-terminal transaction along the nominal path: ledger entries, signed webhooks, audit trail. It is not a façade simulation — it is the real machinery, triggered by hand.

Forcing an outcome
{ "target": "SUCCEEDED" }   // or "FAILED", "EXPIRED"

Called with a production credential, it returns 403. The current list of scenarios is published in GET /api/v1/metasandbox.

Testing

Test scenarios

What you should have exercised before requesting production access. The happy path proves almost nothing.

ScenarioHow to trigger itWhat your system must do
Successful paymentadvance → SUCCEEDEDMark paid once, fulfil
Declined paymentadvance → FAILEDClear message, order reopenable
Customer never confirmsadvance → EXPIREDRelease the basket, offer a retry
Webhook received twiceReplay the delivery from the consoleNo effect the second time
Out-of-order webhookReplay an older eventTrust the status, not arrival order
Connection drop after the callInterrupt the request, then replay the same keyExactly one transaction created
Invalid signaturePost to your endpoint without a signatureRejected with 401
Secret rotationRotate the endpoint secretNo delivery lost for 24 h
Operator unavailableRead a DOWN operatorHide it from the selector
Partial then full refundTwo /refund callsTransition to REVERSED handled
Testing

Going live

  1. Complete your file in the console: legal profile, supporting documents, settlement Mobile Money number, verified phone.
  2. Submit it. Only the account owner can: it is an act that binds the organisation.
  3. Answer compliance requests if any. The exact reason is shown in the console.
  4. Once approved, create a production key from your project.
  5. Switch your environment variables and verify with a small real amount.
Your test integrations stay put

Sandbox keys remain active after going live: your staging environment keeps working exactly as before.

No production key without an approved organisation

The rule is enforced server-side, not merely hidden in the interface. An attempt returns ORG_NOT_APPROVED. Likewise, a suspended organisation cannot create credentials while the suspension lasts.

Merchant console

Overview

One console for merchants, developers, support and administration: menus and permissions differ, the application does not.

Activity

Transactions & balances

Search by reference, status or period; full detail with the event trail and the correlation ID to quote to support.

Technical

Projects, keys, endpoints

One project per integration, its credentials per environment, its notification endpoints and their seven-day health.

Organisation

Team & file

Invitations, roles, activation progress, history of compliance decisions.

Money

Settlements

Payout requests to the Mobile Money number verified in your file, tracked until received.

The console authenticates with a session JWT and consumes the /api/dashboard/* routes. It never uses HMAC signing: that stays reserved for server-to-server exchanges.

Merchant console

Organisations & activation

An organisation is the entity that collects. Its status determines what is allowed.

StatusMeaningSandboxProduction
DraftCreated, file not submittedyesno
Profile incompleteRequired information missingyesno
Ready to submitFile complete on the merchant sideyesno
Under reviewBeing examined by compliance; file frozenyesno
Action requiredAdditional information requested, reason shownyesno
LiveApprovedyesyes
SuspendedProduction operations blockedread onlyno
RejectedApplication refused, reason availableyesno
ClosedRelationship endednono
Two of these statuses are computed

Profile incomplete and Ready to submit are not stored: they are derived in real time from what is actually missing. A status that is both stored and recomputable eventually lies about the state of the file.

Tracking progress through the API

GET/api/merchant-onboardingsingle source for the checklist
Response
{
  "organizationStatus": "READY_TO_SUBMIT",
  "completionPercent": 83,
  "steps": [
    { "code": "EMAIL_VERIFIED",       "status": "DONE" },
    { "code": "SANDBOX_PROJECT",      "status": "DONE" },
    { "code": "FIRST_SANDBOX_TX",     "status": "DONE" },
    { "code": "ORGANIZATION_PROFILE", "status": "DONE" },
    { "code": "PHONE_VERIFIED",       "status": "DONE" },
    { "code": "APPLICATION_FILE",     "status": "IN_PROGRESS" }
  ],
  "nextAction": "SUBMIT_APPLICATION_FILE",
  "statusReason": null
}

Required documents

The list depends on your legal form and is shown in your file. The common core: the representative's ID (front and back) and proof of ownership of the settlement Mobile Money number. Companies additionally provide the RCCM extract and the NIU certificate.

Merchant console

Roles & team

A user can belong to several organisations, with a different role in each.

RoleCanCannot
OwnerEverything, including submitting the file and transferring ownership
AdminManage team, projects, production keys, profileSubmit the file, transfer ownership
DeveloperProjects, sandbox keys, endpoints, rotation, testsCreate the first production key, manage the team
FinanceTransactions, balances, settlements, initiating operationsTouch keys or team
SupportRead transactions and deliveriesAny write
ViewerReadAny write

Switching organisation

POST/api/auth/switch-organization

Switching issues a new token scoped to the chosen organisation. A token carries a single scope: there is no header that lets you target another organisation on a per-request basis.

Merchant console

Projects & credentials

A project represents one integration: a store, an app, a backend.

GET/api/dashboard/applications
POST/api/dashboard/applications/{reference}/credentialscreates or rotates
POST/api/dashboard/applications/{reference}/credentials/{publicKey}/revoke
Merchant console

Console API

The same data as the interface, should you want to build your own dashboards.

GET/api/auth/meidentity, current organisation, memberships
GET/api/dashboard/transactions
GET/api/dashboard/transactions/{reference}
GET/api/dashboard/wallets
POST/api/dashboard/wallets/{reference}/freezecompliance
POST/api/dashboard/operationsinitiate an operation, idempotent
POST/api/dashboard/operations/quote
GET/api/dashboard/fee-rules
GET/api/dashboard/audit-logs
An initiated operation is not an executed one

/api/dashboard/operations creates the transaction and queues it (QUEUED); a worker executes it and posts the ledger entries. A transaction stuck in QUEUED indicates a stopped worker, not a contract problem.

What logs never show

No secret, no signature, no full phone number appears to an unauthorised role. Viewing a supporting document goes through a short-lived signed link and itself leaves an audit trail.

Under the hood

How operations are executed

What happens to a transaction between your API call and your customer's Mobile Money account.

Once the transaction is recorded, B-Cash hands it to its orchestration layer. That layer relays the operation to the relevant operator, follows its outcome and reports it back to the ledger. Your integration needs to know nothing about it: it observes statuses, not mechanisms.

your callrecordedorchestration operatorstatus + webhook

What this means for you

When the outcome is not confirmed

Occasionally an operation cannot be concluded immediately: the operator does not answer in a usable way, or confirmation does not arrive within the expected window. The transaction then moves to PENDING_RECONCILIATION and the funds involved stay reserved until reconciliation settles it.

Resolution is automatic: the transaction reaches a terminal status and the corresponding event is delivered to you. No action is required from you — and that is precisely the point.

Doing nothing is the right action

On transaction.pending_reconciliation: do not ship, do not refund, do not retry. This status does not mean “failed”, it means “not settled yet”. Tell the customer their payment is being verified and wait for the terminal event. Retrying during that window is the surest way to charge them twice.

How long

These situations are rare and usually clear within minutes. If a transaction stays in this state longer than seems reasonable, email support with its reference: reconciliation can be expedited manually.

Integrations

WooCommerce

The shortest path: your store collects payments without you writing any code.

  1. Create a WooCommerce project in the console and note its public key and sandbox secret.
  2. Install the B-Cash extension in WooCommerce, then fill in the key, the secret and the environment.
  3. Copy the notification URL shown by the extension and register it as an endpoint in the console, attached to that project.
  4. Place a test order and force the outcome from the sandbox: the order should turn “paid” on its own.
  5. Once your file is approved, replace the keys with production ones.
B-Cash eventExpected effect on the order
transaction.succeededOrder paid, processing starts
transaction.failedOrder failed, basket reopenable
transaction.expiredOrder cancelled, stock released
transaction.pending_reconciliationOrder on hold — ship nothing
transaction.reversedOrder refunded
Never confirm an order on the browser redirect

The customer may close their phone before the redirect, or reopen it much later. Only the webhook is authoritative. The return page shows a waiting state; the event triggers fulfilment.

The extension sends context as WOOCOMMERCE and the order number in merchantReference: you can therefore trace any transaction from your WooCommerce back office.

Integrations

Mobile apps

The pattern is simple, provided you never invert the roles: your backend holds the secret, never the app.

Sequence
Mobile app          Your backend              B-Cash API
    │                     │                        │
    │  "I want to pay"    │                        │
    │────────────────────>│                        │
    │                     │  POST /v1/auth/token   │   (HMAC-signed)
    │                     │───────────────────────>│
    │                     │<── bt_… (TTL 600 s) ───│
    │<── ephemeral token ─│                        │
    │                                              │
    │  POST /v1/collections  (Bearer bt_…)         │
    │─────────────────────────────────────────────>│
    │<── nextAction: AWAIT_MOBILE_CONFIRMATION ────│
    │                                              │
    │  Waiting screen until expiresAt              │
    │                     │<── signed webhook ─────│
    │<── your push /      │                        │
    │    your polling ────│                        │

Rules on the app side

What must never be done

Embedding the HMAC key in the app, even “obfuscated”. An app can be decompiled in minutes, and an extracted key lets anyone collect in your name until it is revoked.

Integrations

Custom backend

Direct integration: you control everything, so you carry everything.

Pre-production checklist

ItemCheck
SecretsOutside the repository, in environment variables or a vault. Never in a versioned file.
ClockNTP running on every calling server.
IdempotencyKey tied to the order, persisted with it.
WebhooksSignature verified in constant time, raw body, both headers accepted.
DeduplicationTable of handled evt_…, with a uniqueness constraint.
LoggingcorrelationId and reference stored with the order.
Safety netScheduled job polling old non-terminal transactions.
StatusesPENDING_RECONCILIATION treated as a wait, not a failure.
OperatorsList read from the API, not hard-coded.
RotationProcedure written down and rehearsed at least once in sandbox.

Minimal order state machine

Handling an event
async function handleEvent(event) {
  // 1. Deduplication: let the uniqueness constraint do the work
  if (await events.exists(event.id)) return;
  await events.insert(event.id);

  const tx = event.data.object;
  const order = await orders.findByReference(tx.merchantReference);
  if (!order) return;                       // event from another system

  // 2. Trust the STATUS on the object, never the arrival order
  switch (tx.status) {
    case 'SUCCEEDED':               return order.markPaid(tx);
    case 'FAILED':
    case 'EXPIRED':                 return order.markUnpaid(tx);
    case 'REVERSED':                return order.markRefunded(tx);
    case 'PENDING_RECONCILIATION':  return order.markUnderReview(tx);   // wait
    default:                        return;                            // transient state
  }
}
Reference

Statuses & types

Transaction statuses

CREATED, FEE_QUOTED, HELD, QUEUED, PROCESSING, PENDING_CONFIRMATION, SUCCEEDED, FAILED, CANCELLED, REVERSED, EXPIRED, PENDING_RECONCILIATION. Details in Transaction lifecycle.

Transaction types

TypeMeaning
COLLECTIONMobile Money collection into your wallet
MERCHANT_PAYOUTPayout from your wallet to a phone number
MERCHANT_PAYMENTPayment settled from a customer wallet
WALLET_TOPUPWallet top-up
CASH_OUTWithdrawal from a wallet
REFUNDRefund, linked by parentReference
Generic route or dedicated routes

POST /v1/transactions accepts every type, but prefer /v1/collections and /v1/payouts: they fix the type and resolve the wallet for you, which removes a whole class of mistakes.

Reference

Events

EventEmitted whenTypical action
transaction.createdThe transaction is recordedLog it
transaction.pending_confirmationThe customer must confirmShow the waiting state
transaction.succeededThe money movedFulfil
transaction.failedFinal failureReopen the basket
transaction.expiredNo confirmation in timeRelease stock
transaction.reversedRefunds reached the full amountMark refunded
transaction.pending_reconciliationIndeterminate outcomeWait
webhook.testYou trigger a testVerify the chain

The exhaustive, current list is published in GET /api/v1/meta. An unknown event must be ignored without error: new types can appear within 1.x.

Reference

Scopes

ScopeAllows
collections:createCreate a collection
payouts:createCreate a payout
refunds:createIssue refunds
transactions:readRead transactions
wallets:readRead balances
fees:quoteRequest a quote
webhooks:writeManage endpoints

An ephemeral token can only carry a strict subset of the scopes of the key that issued it. Grant each credential the minimum it needs: a store key has no reason to be able to pay out.

Reference

Versioning

The API is at version 1.3. Within 1.x, these guarantees hold:

Practical consequence: your deserialisation must be tolerant. A client that crashes on an unknown field will break at the next minor release.

The apiVersion field on webhooks (e.g. 2026-07-20) dates the envelope format; keep it in your logs, it explains differences between older and newer events.

Reference

Status & support

GET/api/v1/healthpublic
GET/api/v1/metapublic — stable contract

/v1/meta is worth reading at least once: it publishes the enumerations, the exhaustive event list, the signature specification, the idempotency and quoting rules, and the error code catalogue. Everything this page describes is available there in a form your code can consume.

Contacting support effectively

Three items almost always settle an incident:

  1. The correlationId from the error response.
  2. The transaction reference, or your merchantReference.
  3. The precise timestamp and the environment involved.

Write to support@gba-cm.online. Never send a secret or a screenshot containing a key: if you believe a secret has leaked, rotate it first and report afterwards.

This documentation is a first version

It covers the nodes that exist at API version 1.3. The step-by-step guides for the WooCommerce extension and the mobile SDK will be expanded with the real contents of each package.