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 explains what shapes it: what you control, what Wajub decides for you, and what you can observe afterwards.
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: your routing rules first, then the channel's policy, or each connection's priority without one |
| How traffic splits between equals | You, through the weight on your rules and on your connections |
| Which connections are eligible for this exact payment | Wajub |
| Which one goes first among connections left equal | Wajub, from 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, a channel policy or a priority, set once in the Dashboard, 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. Routing always uses the operator channel, and the operator is often only
known once the payer's number is. A request for cm.mobile carrying +237 6 70 00 00 00 is routed
as cm.mtn, and a request that names cm.mtn while carrying an Orange number is refused with a
422 that names the mismatch, rather than being quietly rerouted.
Write rules against the operator channel
Routing always uses the operator channel, such as cm.mtn or sn.wave: cm.mobile with an MTN
number is routed as cm.mtn, so write your rules against cm.mtn. No payment is ever routed on
cm.mobile, plain mobile, or product words like mobile_money, so a routing rule written
against mobile_money matches nothing.
Which connections can take the payment
Before anything is ordered, Wajub keeps only the connections that can carry this exact payment. A connection is tried only when all of these hold:
- the connection is active, and so is the provider: Available or Beta, not in maintenance, coming soon or disabled;
- the provider can carry the payment's channel, and your connection's channel list admits it;
- the connection accepts the payment's currency;
- Wajub can charge through this provider (providers not yet supported are marked Coming soon);
- the connection is not paused on this channel after repeated failures. A paused connection is shown as skipped in the Routing Log.
A country restriction works one level up. When an active restriction on your account covers the
payer's country, the whole payment stops with 403 country_restricted, not the 422 of an empty
list (Restriction errors).
Two of those conditions 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.
The most specific instruction goes first
The connections left are ordered in two steps. Your rules place their connections first. Every other connection is then ordered by the channel's policy when the channel has an active one, or by priority when it has none. So the list always reads the same way: what your rules named, then the rest.
| Stage | What it places | How it orders them |
|---|---|---|
| 1. Routing rules | The target of every rule that matches the payment, each followed by the rule's fallback | Higher rule priority first, then the more specific rule; rules still tied share traffic by weight |
| 2. Channel policy | Every other connection, when the channel has an active policy | Its fixed cascade order, then its objective; a connection outside a guardrail is tried after the others |
| 2. Or priority | Every other connection, when the channel has no active policy | Higher priority first; inside one priority, cost and recent success; weight shares traffic between the connections still equal |
Rules go first, whatever the priority
A connection named by a matching rule is tried before every connection no rule named, whatever the
priority on either. That is what a rule is for: you wrote it for this case, so it wins over the
general order. The rule's fallback provider
comes right behind its target. Routing rules covers which rule wins
when several match.
Then the channel's policy, when it has one
A channel policy is set per operator channel, on Orchestration, Matrix, with the Policy
button on the channel's row. When it is active, it orders every connection no rule placed, and the
priority on your connections only separates the ones it leaves equal.
| Setting | What it does |
|---|---|
| Fixed cascade order | These connections first, in this order |
| Routing objective | Ranks the others. Max acceptance: best recent success rate first. Least cost: lowest fee for this payment first. Fastest: lowest recent latency first |
| Min health % | A connection whose recent success rate on this channel is below it is tried after every connection that meets it |
| Cost ceiling | A connection whose fee for this payment, in the payment's currency, is above it is tried after every connection that meets it |
A connection below your minimum health or above your cost ceiling is tried after the others, including the connections in your cascade order.
A guardrail moves a connection back, it never removes one
A connection outside a guardrail is tried after the others, not dropped. When every connection is above the cost ceiling, the payment still goes through on the best of them. A guardrail cannot cost you a payment.
Then priority, and cost and recent success inside it
On a channel with no active policy, connections are grouped by the priority integer on your
connection, and the highest group is tried in full before the next one is touched. The default is
0, so a merchant who has never set one has every connection in a single group.
Inside one priority, Wajub favours the connection that is cheapest for this payment, from the negotiated rates you entered on your connections (not Wajub's own pricing), and the one that has been succeeding best recently on this channel for your account. Only failures on the provider's or the operator's side count against recent success: a payer's empty wallet does not.
Least cost needs your rates
If you never entered what each connection costs you, Wajub cannot tell your connections apart on cost, and cost separates nothing. Least-cost routing only works once you have entered your negotiated rates. The same goes for the Least cost objective and the cost ceiling of a channel policy.
Connections still equal share traffic by their 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. A connection at weight 0 is only tried
after the ones that have some. Weight shares traffic in the same way between connections a channel
policy leaves equal.
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,
no policy on the channel, 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, the rule's target went first, the second one carried it.
- 1

cinetpayoperator timed out8.0 spriority 50rule matchrecent success - 2

flutterwaveaccepted0.9 spriority 100least costrecent success - 3

pawapaynever calledpriority 50 notchpaySkipped (circuit open). The connection was paused on this channel after repeated failures.
Read it from the bottom. NotchPay was not in the ranking at all, because it was paused on this channel. PawaPay was ranked but sits at priority 50 under Flutterwave's 100, so it would only have been called if both connections above it had failed. CinetPay went first although its priority is 50 too, entirely because of the rule: a connection a matching rule names is tried before the general order, and Flutterwave, cheaper and succeeding well recently, only came next. CinetPay then answered that the operator had timed out, which says nothing about the payer, so the ladder moved on and Flutterwave reached the same wallet in under a second.
Konsole shows why each payment was routed the way it was. The API response does not.
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. The table says what ends it, and what moves it on.
| 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: the provider answers that the operator timed out or is down, that your credentials are refused or that its float is exhausted, or the provider cannot be reached | Moves on to the next candidate, and the failure counts against that route's health |
The request reached the provider and no answer came back in time, or Stripe answered with a 5xx | Stops. The provider may have acted, so the payment stays pending until its webhook or verification settles it |
| Your connection has no credentials for this channel | Moves on: nothing reached that provider. The connection is flagged in the Dashboard |
| Three providers have been tried | Stops, whatever is left in the list |
A card the payer entered in a provider's own fields (Stripe, a saved Paystack card) is the exception: only the provider that holds it can charge it, so it goes to that connection whatever the ranking says, and never to another one. A provider's own payment window and a code or detail the payer confirms afterwards stay with the provider that opened them in the same way (Waterfall & fallback).
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 your call 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 which failures move a payment on, and when a connection is paused.
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, which shows every candidate and why it was placed |
Sandbox does not route
Sandbox payments are not routed: they don't use your connections or your rules, don't appear in the Routing Log and aren't counted in analytics. Connections exist in live mode only. Sandbox is where you test the shape of your integration. Routing is tested live, on small amounts.
Related pages
- Routing RulesWhat a rule can pin down, and which rule wins when several match.
- Waterfall & FallbackWhich failures move a payment on, and when a connection is paused.
- Routing LogThe decision behind a payment that already happened.
- Orchestration QuickstartShape the routing without changing a line of integration code.