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 is how it gets built, in the order the engine builds it.

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, through each connection's priority
How traffic splits between equalsYou, through each connection's weight
Which connections are eligible for this exact paymentWajub
Which one goes first inside a priority tierWajub, from your rules, 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 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.

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. 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.

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.

StepWhat it checksWhen it fails
1. Country blockWhether an active country restriction on your account covers the payer's countryThe whole payment stops. No candidate is loaded at all
2. Your connectionsis_active, on your side and on the provider'sThe connection is dropped
3. ChannelThat the provider can technically carry cm.mtn, and that your own channel list admits itDropped
4. CurrencyThat the connection lists this currencyDropped
5. DriverThat the engine ships code for that providerDropped
6. Circuit breakerThat this route is not currently open after repeated failuresDropped, 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 priorityoptional
A matching routing rule is worth 1000, plus 500 for each of country, channel, currency and amount range that the rule actually pins down, plus the rule's own priority. A rule that pins all four is worth 3000 before its priority is counted. Only the highest-scoring rule for a given provider is kept.
Least cost200optional
Goes to the connection whose computed fee is lowest for this exact amount and channel, and to every connection tied with it. The fee comes from the negotiated rate you entered on the connection, not from Wajub's own pricing.
Live success rate50 or 25optional
50 at 90 % and above, 25 at 70 % and above, nothing below that. Measured over the last 15 minutes, for your account, on this channel, through this provider. Only failures on the path count against it.

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. 1
    cinetpay
    operator timeout8.0 s
    priority 100 · score 2550rule +2500success 94 % +50
  2. 2
    flutterwave
    accepted0.9 s
    priority 100 · score 225least cost +200success 88 % +25
  3. 3
    pawapay
    never called
    priority 50 · score 0
  4. notchpay

    Skipped 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 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: timeout, unreachable, rejected credentials, float exhaustedMoves on to the next candidate, and the failure counts against that route's health
Three providers have been triedStops, whatever is left in the list

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.

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.PVrU8x2k",
"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, one record per payment

What did you think of this content?