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 →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.
Collections, payouts, fee quotes, balances, refunds, webhooks. Signed authentication, whole-XAF amounts, mandatory idempotency on every money-moving write.
Start with authentication →Organisations, roles, activation file, projects and keys, webhook delivery log, settlements and audit trail.
Explore the console →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 →WooCommerce extension, mobile apps through ephemeral tokens, or direct integration from your own backend.
Choose your integration →| Role | Host | Used for |
|---|---|---|
| API | api.gba-cm.online | All /api/v1/* and /api/dashboard/* calls |
| Merchant console | dashboard.gba-cm.online | Merchant interface and back office |
| Documentation | docs.gba-cm.online | This page |
+237650000000.null field mean the same thing: the value is not known.Five nodes, one path for the money. Knowing who talks to whom answers half of all support questions.
| Node | Responsibility | Who 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 |
Your code never talks to the operators. It creates a transaction, then waits: either by polling the transaction, or — preferably — by receiving a webhook.
From an empty account to your first successful test collection.
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.
# 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.
{
"url": "https://store.example.cm/webhooks/bcash",
"events": ["transaction.succeeded", "transaction.failed"]
}
# → 201 { "reference": "whk_…", "secret": "whsec_…" } ← the secret appears only here
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" }'
In testing, nobody is going to type a PIN: trigger the outcome yourself.
{ "target": "SUCCEEDED" }
# The transaction follows the nominal path: ledger entries,
# a signed transaction.succeeded webhook, and an audit trail.
Your endpoint just received a signed event. What remains is verifying its signature (see how) and marking your order as paid.
Two environments, one API, separate keys that never cross.
| Sandbox | Production | |
|---|---|---|
| Available | From sign-up | Once your file is approved |
| Money | No real movement | Real debits and credits |
| Transaction outcome | You force it (/sandbox/…/advance) | The customer confirms on their phone |
| Webhooks | Delivered, signed, replayable | Identical |
livemode field | false | true |
X-BCash-Key. Visible in the console.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.
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.
Three ways to authenticate, for three kinds of caller. Picking the right one is the first architectural decision of any integration.
| Method | For | Headers |
|---|---|---|
| HMAC key | Your servers, and only them | X-BCash-Key, X-BCash-Timestamp, X-BCash-Signature |
| Ephemeral token | Mobile app or web page | Authorization: Bearer bt_… |
| Session JWT | Console users | Authorization: Bearer … |
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.
The string to sign concatenates four elements separated by newlines:
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
$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.
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');
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.
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.
// Request, HMAC-signed from YOUR server { "scopes": ["collections:create", "transactions:read"], "ttl": 600 } // Response { "token": "bt_9f2c…", "expiresIn": 600, "scopes": ["collections:create", "transactions:read"] }
Bearer bt_….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.
| Situation | API response |
|---|---|
| First request with this key | The operation runs and the response is stored |
| Same key, same body | The stored response is returned — no second operation |
| Same key, different body | 409 IDEMPOTENCY_CONFLICT |
| Header missing | 400 — the request is rejected |
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.
// Once, when the order is created: order.paymentIdempotencyKey ??= crypto.randomUUID(); // Every payment attempt for THIS order reuses that key.
If you lose the response, don't recreate anything: look it up.
Both routes exist for precisely this situation. The second assumes you set
merchantReference at creation — always do.
The CFA franc has no sub-unit. That simplicity is a trap for anyone coming from a cents-based API.
amount is a strictly positive integer: 25000 means
twenty-five thousand francs.currency is XAF. The field exists for the future; today it has one value.amount (what was requested),
feeAmount (the fees) and netAmount (what you keep). Display the one that
answers the question asked, not the most flattering one.Every error shares the same envelope, and each one carries what support needs to find it.
{
"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
}
}
code, never on message: the text may be
rewritten, the code is a contract.retryable: true — replay the same request with the
same Idempotency-Key, honouring retryAfter.retryable: false — retrying changes nothing. Fix the request or
surface the error to the user.correlationId next to your order reference. It turns a support
ticket into a three-minute diagnosis.| Status | Meaning | What to do |
|---|---|---|
| 400 | Malformed request | Fix the body or headers |
| 401 | Invalid signature or token | Check key, secret, clock |
| 403 | Missing scope, or forbidden environment | Check the credential's scopes |
| 404 | Resource absent from your scope | Check the reference |
| 409 | State or idempotency conflict | Read code: the current state forbids the action |
| 422 | Well-formed but business-invalid | Read details |
| 429 | Too many requests | Wait for retryAfter |
| 500 | Incident on the B-Cash side | Retry with the same key, then report the correlationId |
| 503 | Temporarily unavailable | Retry with increasing back-off |
The exhaustive catalogue of machine codes is published by the API itself:
GET /api/v1/meta → errors.catalog. Consume it rather than copying a list
that will age.
Collections are paginated server-side. Never fetch everything to filter in memory.
| Parameter | Purpose |
|---|---|
page | Page number, starting at 1 |
itemsPerPage | Page size; capped server-side |
status | Filter on a status from the stable list |
merchantReference | Your own reference |
The response carries the total; don't recompute it from the number of items received.
The list is dynamic: it carries each operator's service state.
| State | Meaning | Expected UI behaviour |
|---|---|---|
| ACTIVE | Normal service | Offer the operator |
| DEGRADED | Slowness or abnormal failure rate | Offer it, warning about possible delays |
| DOWN | Unavailable | Hide or grey out, with no redeploy |
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.
Two different questions hide behind “how much does it cost”. The API asks which one you are asking.
amountBasis | What amount represents | Who absorbs the fees |
|---|---|---|
GROSS (default) | The amount debited from the customer | You. merchantNetAmount = amount − fees |
ORDER_NET | The net you want to receive | The customer. customerPayableAmount = amount + fees, computed exactly |
// 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 }
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.
ruleVersion identifies the pricing grid applied: keep it with your order, it
explains a discrepancy six months later.The route to use nine times out of ten: it fixes the type and resolves your destination wallet on its own.
collections:create| Field | Required | Description |
|---|---|---|
amount | yes | Whole XAF. With feeQuoteId, must equal customerPayableAmount. |
operator | yes | Code from GET /v1/operators. |
customer.phone | yes | International format, e.g. +237650000000. |
customer.displayName | no | Name shown in tracking views. |
merchantReference | no | Your own reference — set it, it will save you one day. |
feeQuoteId | no | Quote to consume. |
context | no | Call origin: WOOCOMMERCE, MOBILE_APP, API_DIRECT… |
metadata | no | Flat object, max 32 keys, max 4,096 bytes. The bcash_ prefix is reserved. |
callbackUrl | — | Deprecated. Accepted, but never used for delivery. |
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.
{
"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"
}
nextActionThis block tells you what to display while waiting, so you don't have to invent one message per operator:
AWAIT_MOBILE_CONFIRMATION — the customer must approve on their phone. Show
displayMessage and a countdown to expiresAt.AWAIT_PROCESSING — nothing for the customer to do, execution is under way.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.
Sending money from your balance to a Mobile Money number.
payouts:create{
"amount": 50000, // what the recipient RECEIVES
"operator": "ORANGE",
"recipient": { "phone": "+237690000000", "name": "ALBA Supplies" },
"merchantReference": "INV-2026-118"
}
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.
Twelve statuses, four terminal ones. A transaction is never “maybe paid”.
| Status | Meaning | Terminal |
|---|---|---|
| CREATED | The transaction is recorded, nothing is committed. | no |
| FEE_QUOTED | Fees are fixed for this transaction. | no |
| HELD | The required funds are reserved on the source wallet. | no |
| QUEUED | Queued for execution. Waiting for a worker or a device. | no |
| PROCESSING | Executing at the operator. No longer cancellable. | no |
| PENDING_CONFIRMATION | The customer must enter their PIN. | no |
| SUCCEEDED | The money moved. Fulfil the order. | yes |
| FAILED | Refusal, insufficient balance, wrong PIN… Nothing moved. | yes |
| CANCELLED | Cancelled before execution. Reserved funds released. | yes |
| EXPIRED | The customer did not confirm in time. | yes |
| REVERSED | Fully refunded. | yes, after SUCCEEDED |
| PENDING_RECONCILIATION | Indeterminate outcome: neither success nor failure. Under reconciliation. | no |
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.
Two strategies, and one is clearly better:
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.
refunds:createCOLLECTION and MERCHANT_PAYMENT in SUCCEEDED.REFUND, linked through
parentReference.amount, the outstanding balance is refunded in full; otherwise the refund
is partial and can be repeated.REVERSED and transaction.reversed is emitted.POST /api/v1/transactions/TRX7QK4M2/refund
Idempotency-Key: 3d9a1c77-2f40-4b8e-9c15-77e0a2b4d611
{ "amount": 5000, "reason": "Missing item" }
A balance is not a single number: three compartments coexist.
| Compartment | What it holds |
|---|---|
available | Usable right now: payouts, settlements. |
reserved | Locked by operations in flight. Neither available nor lost. |
pending | Collections 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.
You don't wait on the API: it calls you when state changes.
{
"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" }
}
}
id. The same evt_… can arrive twice;
your handler must be a no-op the second time.transaction.succeeded may arrive before a
transaction.pending_confirmation. Trust the status carried by the object, not the
arrival order.The one non-negotiable step of the integration.
| Header | Content |
|---|---|
X-BCash-Timestamp | Unix seconds at emission |
X-BCash-Signature | HMAC_SHA256(secret, timestamp + "\n" + raw_body) |
X-BCash-Signature-Previous | Present for 24 h after a rotation: signature with the old secret |
// 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 });
$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; }
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.
webhooks:writewebhook.testAn 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.
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.
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.
| Situation | Behaviour |
|---|---|
| You answer 2xx | Delivery succeeded, done. |
| You answer something else, or nothing | Further attempts, at increasing intervals. |
| Repeated failures | The endpoint moves to FAILING; the console alerts you. |
| Endpoint unreachable for a long time | It may be disabled; re-enable it once fixed. |
The console keeps every attempt: HTTP code, response time, returned body. A delivery can be replayed manually.
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.
The exact parameters (attempt count, intervals, tolerance window) are published by the API:
GET /api/v1/meta → webhookDelivery. Read them rather than assuming them;
they can be tuned without a major version change.
Same code, same responses, same webhooks — with no money at stake.
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.
{ "target": "SUCCEEDED" } // or "FAILED", "EXPIRED"
Called with a production credential, it returns 403. The current
list of scenarios is published in GET /api/v1/meta → sandbox.
What you should have exercised before requesting production access. The happy path proves almost nothing.
| Scenario | How to trigger it | What your system must do |
|---|---|---|
| Successful payment | advance → SUCCEEDED | Mark paid once, fulfil |
| Declined payment | advance → FAILED | Clear message, order reopenable |
| Customer never confirms | advance → EXPIRED | Release the basket, offer a retry |
| Webhook received twice | Replay the delivery from the console | No effect the second time |
| Out-of-order webhook | Replay an older event | Trust the status, not arrival order |
| Connection drop after the call | Interrupt the request, then replay the same key | Exactly one transaction created |
| Invalid signature | Post to your endpoint without a signature | Rejected with 401 |
| Secret rotation | Rotate the endpoint secret | No delivery lost for 24 h |
| Operator unavailable | Read a DOWN operator | Hide it from the selector |
| Partial then full refund | Two /refund calls | Transition to REVERSED handled |
Sandbox keys remain active after going live: your staging environment keeps working exactly as before.
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.
One console for merchants, developers, support and administration: menus and permissions differ, the application does not.
Search by reference, status or period; full detail with the event trail and the correlation ID to quote to support.
One project per integration, its credentials per environment, its notification endpoints and their seven-day health.
Invitations, roles, activation progress, history of compliance decisions.
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.
An organisation is the entity that collects. Its status determines what is allowed.
| Status | Meaning | Sandbox | Production |
|---|---|---|---|
| Draft | Created, file not submitted | yes | no |
| Profile incomplete | Required information missing | yes | no |
| Ready to submit | File complete on the merchant side | yes | no |
| Under review | Being examined by compliance; file frozen | yes | no |
| Action required | Additional information requested, reason shown | yes | no |
| Live | Approved | yes | yes |
| Suspended | Production operations blocked | read only | no |
| Rejected | Application refused, reason available | yes | no |
| Closed | Relationship ended | no | no |
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.
{
"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
}
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.
A user can belong to several organisations, with a different role in each.
| Role | Can | Cannot |
|---|---|---|
| Owner | Everything, including submitting the file and transferring ownership | — |
| Admin | Manage team, projects, production keys, profile | Submit the file, transfer ownership |
| Developer | Projects, sandbox keys, endpoints, rotation, tests | Create the first production key, manage the team |
| Finance | Transactions, balances, settlements, initiating operations | Touch keys or team |
| Support | Read transactions and deliveries | Any write |
| Viewer | Read | Any write |
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.
A project represents one integration: a store, an app, a backend.
The same data as the interface, should you want to build your own dashboards.
/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.
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.
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.
GET /v1/operators is for: read it rather than assuming
everything is always reachable.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.
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.
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.
The shortest path: your store collects payments without you writing any code.
| B-Cash event | Expected effect on the order |
|---|---|
transaction.succeeded | Order paid, processing starts |
transaction.failed | Order failed, basket reopenable |
transaction.expired | Order cancelled, stock released |
transaction.pending_reconciliation | Order on hold — ship nothing |
transaction.reversed | Order refunded |
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.
The pattern is simple, provided you never invert the roles: your backend holds the secret, never the app.
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 ────│ │
nextAction.displayMessage rather than a hard-coded string: it is adapted to
the chosen operator.expiresAt: show a countdown, and above all conclude nothing before it.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.
Direct integration: you control everything, so you carry everything.
| Item | Check |
|---|---|
| Secrets | Outside the repository, in environment variables or a vault. Never in a versioned file. |
| Clock | NTP running on every calling server. |
| Idempotency | Key tied to the order, persisted with it. |
| Webhooks | Signature verified in constant time, raw body, both headers accepted. |
| Deduplication | Table of handled evt_…, with a uniqueness constraint. |
| Logging | correlationId and reference stored with the order. |
| Safety net | Scheduled job polling old non-terminal transactions. |
| Statuses | PENDING_RECONCILIATION treated as a wait, not a failure. |
| Operators | List read from the API, not hard-coded. |
| Rotation | Procedure written down and rehearsed at least once in sandbox. |
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 } }
CREATED, FEE_QUOTED, HELD, QUEUED,
PROCESSING, PENDING_CONFIRMATION, SUCCEEDED,
FAILED, CANCELLED, REVERSED, EXPIRED,
PENDING_RECONCILIATION. Details in Transaction lifecycle.
| Type | Meaning |
|---|---|
COLLECTION | Mobile Money collection into your wallet |
MERCHANT_PAYOUT | Payout from your wallet to a phone number |
MERCHANT_PAYMENT | Payment settled from a customer wallet |
WALLET_TOPUP | Wallet top-up |
CASH_OUT | Withdrawal from a wallet |
REFUND | Refund, linked by parentReference |
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.
| Event | Emitted when | Typical action |
|---|---|---|
transaction.created | The transaction is recorded | Log it |
transaction.pending_confirmation | The customer must confirm | Show the waiting state |
transaction.succeeded | The money moved | Fulfil |
transaction.failed | Final failure | Reopen the basket |
transaction.expired | No confirmation in time | Release stock |
transaction.reversed | Refunds reached the full amount | Mark refunded |
transaction.pending_reconciliation | Indeterminate outcome | Wait |
webhook.test | You trigger a test | Verify 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.
| Scope | Allows |
|---|---|
collections:create | Create a collection |
payouts:create | Create a payout |
refunds:create | Issue refunds |
transactions:read | Read transactions |
wallets:read | Read balances |
fees:quote | Request a quote |
webhooks:write | Manage 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.
The API is at version 1.3. Within 1.x, these guarantees hold:
callbackUrl, ignored in favour of registered endpoints.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.
/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.
Three items almost always settle an incident:
correlationId from the error response.reference, or your merchantReference.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.
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.