Payment Orchestration
How Wajub picks a provider for every payment, and what you can change about it.
You never name a provider on a payment. There is no provider field on the request and no way to
add one. For every live payment, Wajub builds a ranked list of the connections that could carry it,
tries them from the top, and walks down the list when one fails on its way to the operator.
That list is the orchestration decision. This page is how it gets built, in the order the engine builds it.
1You name no provider
One call carries an amount and a channel. Wajub picks.
2A refusal is not a charge
The payer keeps one reference through the whole ladder.
3The first yes ends it
A provider accepts and nothing else is tried.
Who decides what
Orchestration is not a feature you turn on. It is the only path a live payment takes, and the division of labour never changes.
| Decision | Made by |
|---|---|
| Which providers you are connected to | You, in the Dashboard |
| Which channels each connection may carry | You |
| The order the connections are tried in | You, through each connection's priority |
| How traffic splits between equals | You, through each connection's weight |
| Which connections are eligible for this exact payment | Wajub |
| Which one goes first inside a priority tier | Wajub, from your rules, your costs, and live success rates |
| When to stop trying | Wajub |
Nothing in that table is expressible at the call site. If you need a specific provider for a specific case, that is a routing rule or a priority, set once, not a parameter you send.
The route is decided on the second call
A payment is two calls, and only the second one routes anything.
| Call | What it fixes |
|---|---|
POST /payments | The amount, the currency, the customer. No provider is looked at |
POST /payments/{id} | The channel and the phone number, and therefore the route |
The reason is the channel. The router works on an exact operator slug, and that slug often does not
exist until the payer's number is known. A request for cm.mobile carrying +237 6 70 00 00 00
becomes cm.mtn before a single candidate is loaded, and a request that names cm.mtn while
carrying an Orange number is refused right there, with a 422 that names the mismatch, rather than
being quietly rerouted.
The router never sees a generic channel
By the time the candidate list is built, the channel is always a resolved operator slug such as
cm.mtn or sn.wave. cm.mobile, plain mobile, and product-level words like mobile_money
never reach it. A routing rule written against mobile_money matches nothing, ever.
Six filters, then a ranking
Eligibility is decided before anything is scored. Each filter is a plain yes or no, and a connection that fails one is simply not in the list.
| Step | What it checks | When it fails |
|---|---|---|
| 1. Country block | Whether an active country restriction on your account covers the payer's country | The whole payment stops. No candidate is loaded at all |
| 2. Your connections | is_active, on your side and on the provider's | The connection is dropped |
| 3. Channel | That the provider can technically carry cm.mtn, and that your own channel list admits it | Dropped |
| 4. Currency | That the connection lists this currency | Dropped |
| 5. Driver | That the engine ships code for that provider | Dropped |
| 6. Circuit breaker | That this route is not currently open after repeated failures | Dropped, and recorded as a skip with its reason |
Two of those filters are permissive when left empty, which surprises people the first time. A connection with no channel list accepts every channel the provider globally supports, and a connection with no currency list accepts every currency. Narrowing is opt-in, not the default.
Priority orders the list, the score only breaks ties
This is the part worth getting exactly right, because the two stages are not equals.
Priority is absolute
Candidates are grouped by the priority integer on your connection and the highest group is tried
in full before the next one is touched. A connection at priority 50 is never tried ahead of one at
priority 100, whatever it costs, whatever rule points at it, however well it has been performing.
Priority is the one control that is not a suggestion.
The default is 0, so a merchant who has never set one has every connection in a single tier, and
the whole ordering falls to the score below.
The score is three additions, inside one tier
Rule matchup to 3000 + the rule priorityoptionalLeast cost200optionalLive success rate50 or 25optionalCost is the weakest of the three, by a factor of fifteen
A fully specified rule is worth 3000 and the cheapest connection is worth 200, so a rule wins over price every time they disagree. And if you never entered what each connection costs you, every connection computes to a fee of zero, every connection ties for cheapest, and the 200 lands on all of them at once, which is the same as it landing on none. Least-cost routing only discriminates once you have told Wajub your negotiated rates.
Equal scores go to a draw weighted by each connection's weight, which defaults to 100. Set two
connections to the same priority with weights of 70 and 30 and, when nothing else separates them,
roughly seven payments in ten will open on the first. The draw only decides who goes first; the
others keep their order behind it as fallbacks.
One decision, read end to end
A 25 000 XAF payment on cm.mtn, on an account with four connections that can reach that number
and a rule pinning country, channel and currency to CinetPay. All four end at the same MTN wallet.
What the ranking decides is which one carries it there.
Routing decision · cm.mtn · 25 000 XAF
Four connections reach the same wallet. One was out of rotation, three were ranked, the second one carried it.
- 1

cinetpayoperator timeout8.0 spriority 100 · score 2550rule +2500success 94 % +50 - 2

flutterwaveaccepted0.9 spriority 100 · score 225least cost +200success 88 % +25 - 3

pawapaynever calledpriority 50 · score 0 notchpaySkipped before ranking. Its circuit is open after repeated path failures on this channel.
Read it from the bottom. NotchPay never entered the ranking at all, because its circuit was open. PawaPay entered it but sits at priority 50, so it would only have been called if both priority-100 connections had failed. Inside priority 100, CinetPay went first on 2550 against Flutterwave's 225, entirely because of the rule: Flutterwave was cheaper and, on cost and telemetry together, the better-scoring choice on everything except the rule. CinetPay then timed out on the way to the operator, which says nothing about the payer, so the ladder moved on and Flutterwave reached the same wallet in under a second.
Every one of those numbers is recorded per payment, and you read them back in Konsole rather than in an API response.
What stops the ladder
The cascade is not a retry policy. It is a single pass down the list, inside the
POST /payments/{id} call, and four things end it.
| What happens | What the ladder does |
|---|---|
A provider accepts, including accepting as pending | Stops. That provider owns the payment now |
| The payer refuses: empty wallet, wrong number, operator limit, prompt left to expire | Stops. A second provider would ask the same person the same question |
| The path fails: timeout, unreachable, rejected credentials, float exhausted | Moves on to the next candidate, and the failure counts against that route's health |
| Three providers have been tried | Stops, whatever is left in the list |
There is no failover once the push is accepted
A mobile money provider returns pending the moment the operator agrees to prompt the customer,
seconds before anyone has touched their phone. That is an acceptance, and it ends the ladder. If
the customer then never confirms, the payment fails minutes later through verification or a
provider webhook, long after the executor returned, and no other provider is tried. Failing over
at that point would prompt the same phone twice. Trying again is your decision, and it is a new
payment.
Waterfall & fallback covers the failure classification and the circuit breaker in full.
The two ways it ends badly
They are different failures with different answers, and the status code tells you which one you have without reading the message.
An empty candidate list answers 422. Nothing was tried, so nothing can be retried elsewhere: no
connection of yours can carry this channel and currency today, or the ones that could are all out
of rotation.
A list that was walked and exhausted answers 402, and so does a single terminal refusal such as a
decline. error_code carries the provider's own machine-readable reason when there is one, which
there is not when every candidate failed without saying why.
Note the status in both cases. A failed attempt does not kill the payment: it goes back to
pending, and the payer can try again on the same payment with another number or another card.
Only your own code decides whether to offer them that.
What the API tells you, and what it does not
Orchestration is deliberately invisible in the payment object, and knowing where the seams are saves you looking for fields that are not there.
| Question | Answer |
|---|---|
| Which provider ran my payment | Not in the API. A payment carries no provider, no attempt count, no routing block |
| Which provider ran my payout | provider and provider_reference, on the transfer itself |
| Which connections do I have, and what do they carry | GET /providers, which returns the connection and its channels, but not its priority or weight |
| How do I create a routing rule from code | You do not. Rules are set in the Dashboard |
| Why was this payment routed the way it was | Konsole, Routing Log, one record per payment |
Sandbox does not route
A sandbox charge never reaches the orchestrator. The sandbox path resolves a single synthetic provider, so there is no candidate list, no rule evaluation, no circuit breaker, nothing written to the Routing Log, and nothing counted in analytics. Sandbox is where you test the shape of your integration. Routing is tested live, on small amounts.
Related pages