Skip to content

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.

cinetpayrank 1timed outflutterwaverank 2capturedpawapayrank 3not callednotchpayrank 4not calledpaystackrank 5not calledtrx.8f2a
  1. 1You name no provider

    One call carries an amount and a channel. Wajub picks.

  2. 2A refusal is not a charge

    The payer keeps one reference through the whole ladder.

  3. 3The first yes ends it

    A provider accepts and nothing else is tried.

One payment leaving a checkout. Wajub holds the ranked connections for the account, calls the first, and moves down when it stalls. The payer sees one transaction and one wallet, whichever connection carried it.

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.

DecisionMade by
Which providers you are connected toYou, in the Dashboard
Which channels each connection may carryYou
The order the connections are tried inYou: your routing rules first, then the channel's policy, or each connection's priority without one
How traffic splits between equalsYou, through the weight on your rules and on your connections
Which connections are eligible for this exact paymentWajub
Which one goes first among connections left equalWajub, from your costs and live success rates
When to stop tryingWajub

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.

CallWhat it fixes
POST /paymentsThe 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.

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.

StageWhat it placesHow it orders them
1. Routing rulesThe target of every rule that matches the payment, each followed by the rule's fallbackHigher rule priority first, then the more specific rule; rules still tied share traffic by weight
2. Channel policyEvery other connection, when the channel has an active policyIts fixed cascade order, then its objective; a connection outside a guardrail is tried after the others
2. Or priorityEvery other connection, when the channel has no active policyHigher 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.

SettingWhat it does
Fixed cascade orderThese connections first, in this order
Routing objectiveRanks 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 ceilingA 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.

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. 1
    cinetpay
    operator timed out8.0 s
    priority 50rule matchrecent success
  2. 2
    flutterwave
    accepted0.9 s
    priority 100least costrecent success
  3. 3
    pawapay
    never called
    priority 50
  4. notchpay

    Skipped (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 happensWhat the ladder does
A provider accepts, including accepting as pendingStops. That provider owns the payment now
The payer refuses: empty wallet, wrong number, operator limit, prompt left to expireStops. 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 reachedMoves 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 5xxStops. The provider may have acted, so the payment stays pending until its webhook or verification settles it
Your connection has no credentials for this channelMoves on: nothing reached that provider. The connection is flagged in the Dashboard
Three providers have been triedStops, 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).

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.

Response · 422 Unprocessable Content
{
"code": 422,
"status": "Unprocessable Content",
"message": "No eligible payment provider found for this transaction.",
"errors": {
"channel": [
"No eligible payment provider found for this transaction."
]
}
}

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.

Response · 402 Payment Required
{
"code": 402,
"status": "Payment Required",
"message": "Insufficient balance",
"error_code": "insufficient_funds",
"transaction": {
"id": "trx_CSUGajfv9xh0XQ5wu2lx",
"status": "pending"
}
}

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.

QuestionAnswer
Which provider ran my paymentNot in the API. A payment carries no provider, no attempt count, no routing block
Which provider ran my payoutprovider and provider_reference, on the transfer itself
Which connections do I have, and what do they carryGET /providers, which returns the connection and its channels, but not its priority or weight
How do I create a routing rule from codeYou do not. Rules are set in the Dashboard
Why was this payment routed the way it wasKonsole, Routing Log, which shows every candidate and why it was placed

What did you think of this content?