Routing plans
Build a campaign's routing plan: choose between priority, weighted and highest-bid ordering, understand how failover walks the plan inside its budget, decide what an unrouted caller gets and read the plan's live indicators.
After this guide you will be able to put targets on a campaign in the order you mean, predict which one a given call reaches first, know exactly how long a caller can be kept waiting while targets are tried, and choose what happens when nobody answers.
A routing plan exists because selling a call is rarely one decision. You have a buyer you prefer, a second who takes what the first cannot, perhaps a few who bid in real time, and an office line for whatever is left. The plan is that list, the campaign's routing mode is how the list is put in order for each call, and failover is what moves a call down it.
What a plan is made of
A plan is a list of routes. Each route attaches one target to the campaign and says how that target is treated here. The target itself — its destination, price, hours and caps — lives under Targets and can be on many campaigns' plans, with different route settings on each.
| Field | Range | Meaning |
|---|---|---|
targetId | one of your targets | Required. Not archived, and each target appears once in a plan. |
priority | 0–1000 | Lower is tried first. Routes with the same priority form a tier. Left out, it takes the route's position in the list, starting at 1. |
weight | 1–10 000 | The route's share of first place among the routes it is drawn against. Default 1. |
active | true or false | Switch a route off without losing its settings. An inactive route is not judged at all: it never appears as skipped. Default true. |
revenueOverrideCents | 0–100 000 000 or null | What a converted call is worth on this campaign, when that differs from the target's own revenue. null uses the target's. Ignored for a target that bids. |
durationThresholdSeconds | 0–7200 or null | The talk time at which a call converts on this campaign. null uses the target's own. |
maxBidCents | 0–100 000 000 or null | A ceiling on a bidding target's price for this campaign. See External RTB targets for where it applies. |
effectiveRevenueCents and effectiveDurationThresholdSeconds: the override if there is one, otherwise the target's own figure.How a call walks the plan
01
Judge
every active route, for this caller, now
02
Ping
bidding targets, in parallel, one budget
03
Order
by the campaign's routing mode
04
Reserve and dial
a slot on the target, its buyer and its line
05
Fail over
release, move down, inside the budget
06
Unrouted action
when the plan or the budget is spent
The plan is only reached once the call has passed the campaign's gates — plan active, wallet funded, campaign live, caller allowed, inside hours, under the campaign's caps. Those are covered in Campaigns overview. A call that fails a gate never looks at the plan.
How a target is judged
Every active route is tested against this caller at this second. The first test a target fails is its skip reason; the call carries on with the rest. A skip is not an error — it is the plan doing its job — and each one is written into the call's waterfall with a sentence that names the target, the cap and the figure.
| Tested, in this order | Skip reason |
|---|---|
The target is active | paused |
Its buyer is active | buyer_paused |
| It has somewhere to send the call: a phone number or SIP address, or a bid URL for a bidding target | no_destination |
| It has not been switched off after repeated failures | suppressed |
| The call is inside the target's hours | closed |
| The caller's state is one the target accepts. A caller whose state is unknown is not skipped | state |
| The target has a free concurrent slot | concurrency |
| Its buyer has a free concurrent slot | buyer_capped |
| The target's hourly, daily, monthly and lifetime caps have room | capped |
| The buyer's hourly, daily, monthly and daily revenue caps have room | buyer_capped |
| The target's daily revenue cap has room | spend_capped |
| This caller has not already reached the target inside its duplicate window | duplicate |
| The call's tags pass the campaign's filters | tag_filter |
| For a bidding target: the buyer answered the ping with a usable bid | no_bid, timeout, error |
Nothing is reserved at this stage. A cap read here is a judgement — "this target is full, do not bother" — and the guarantee is made later, at the dial. Two calls arriving together can both be told a target has room; exactly one gets the last slot, and the other sees concurrency against that target and moves down its plan.
The three routing modes
| Mode | How the eligible routes are ordered | Use it when |
|---|---|---|
priority | Lowest priority first. Inside a tier, the order is a weighted draw. | You have a preferred buyer and others who catch what they cannot take. |
weighted | One weighted draw across every eligible route. priority is ignored. | You are splitting traffic on purpose: a test, or a contractual share. |
highest_bid | Highest price first, then lowest priority, then highest weight. | Targets bid in real time, or fixed prices differ enough that price should decide. |
routingMode. It is saved together with the routes, because it decides what their order means.A weighted draw works like this: one route is drawn for first place with a probability of its weight over the total, it is taken out, and the draw is repeated for second place, and so on. A route with twice the weight is twice as likely to be first — and, having lost the first draw, twice as likely to be second. The draw is random for every call, so the proportions hold over many calls, not inside any particular four.
Example 1: a priority ladder with a weighted tier
Acme Health is your main buyer and BlueSky Legal has asked for a quarter of the calls. Acme's overflow line takes anything neither of them answers, at a lower price on this campaign.
{
"mode": "priority",
"routes": [
{
"targetId": "a9b8c7d6-e5f4-4a3b-9c2d-1e0f9a8b7c6d",
"priority": 1,
"weight": 3
},
{
"targetId": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f",
"priority": 1,
"weight": 1
},
{
"targetId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
"priority": 2,
"weight": 1,
"revenueOverrideCents": 2500
}
]
}- Tier 1 holds two routes with weights 3 and 1. When both are eligible, Acme Health — Dallas floor is first in line for about three calls in four and BlueSky Legal — Intake for about one in four.
- Whichever of them was not drawn first is second. A call that Dallas does not answer fails over to BlueSky before it ever reaches tier 2, and the other way round.
- Acme Health — Overflow is always last, and a call that converts there books
2500cents, not the target's own revenue, because of the override. - If Dallas reaches its daily cap at three in the afternoon, it is skipped with
cappedfor the rest of the window and BlueSky is first for every tier-1 call. Weights share out first place among the routes that are eligible, not among the routes on the page.
Example 2: a pure weighted split
You are testing BlueSky against Acme and want 60 per cent of calls to try Acme first, whatever order the routes are listed in.
{
"mode": "weighted",
"routes": [
{
"targetId": "a9b8c7d6-e5f4-4a3b-9c2d-1e0f9a8b7c6d",
"weight": 60
},
{
"targetId": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f",
"weight": 40
}
]
}- The routes get priorities 1 and 2 from their position, and in
weightedmode those are ignored: both routes go into one draw. - The saved plan reports
sharePcton each active route — here60and40. It is the route's weight over the total weight of the active routes, to one decimal place, and it is only reported inweightedmode. In the other two modes weight only settles order inside a tier or breaks a tie, and a percentage would claim a split that does not happen. - A failed dial still fails over. The 40 per cent of calls that try BlueSky first go to Acme next if BlueSky does not answer.
Example 3: highest bid with a ring tree
A ring tree is what a highest-bid plan becomes for one call: the buyers who want it, rung in price order until one answers. Here two targets bid in real time, one pays a fixed price, and the overflow line earns nothing on this campaign.
{
"mode": "highest_bid",
"routes": [
{
"targetId": "d3e4f5a6-b7c8-4d9e-8f1a-2b3c4d5e6f7a",
"priority": 1
},
{
"targetId": "e4f5a6b7-c8d9-4e0f-9a2b-3c4d5e6f7a8b",
"priority": 1
},
{
"targetId": "a9b8c7d6-e5f4-4a3b-9c2d-1e0f9a8b7c6d",
"priority": 2
},
{
"targetId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
"priority": 3,
"revenueOverrideCents": 0
}
]
}A call arrives from a Texas caller. Both bidding targets pass every test above, so both are pinged at the same moment, under one time budget:
| Target | What happened | Price for this call |
|---|---|---|
| BlueSky Legal — RTB | Answered the ping in 310 ms with a bid of $41.00 and a phone number to send the call to. | 4100 |
| Acme Health — RTB | Did not answer inside its ping time-out. Skipped with timeout: "The bidding endpoint of the buyer Acme Health did not answer within 1,500 ms." | — |
| Acme Health — Dallas floor | Fixed price. It "bids" its revenue. | 4200 |
| Acme Health — Overflow | Fixed price, overridden to nothing on this campaign. | 0 |
- The order is by price: Dallas floor (
4200), then BlueSky RTB (4100), then Overflow (0). A fixed price can outrank a live bid — price decides, not how the price was arrived at. - Dallas is dialled first. It rings out. Its slot is released and the call fails over.
- BlueSky RTB is dialled on the number its bid named. It answers.
- The call now carries BlueSky's price and BlueSky's duration threshold — the one its bid stated, or the target's own if the bid stated none. If the caller talks past that threshold, the call converts for
4100.
- Two equal prices are ordered by
priority, lowest first, then byweight, highest first. That is what the priorities in the plan above are for. - A bid below the target's minimum bid is treated as no bid at all: the target is skipped with
no_bid. - A price changed by a tag filter is the price the plan orders on.
- In
highest_bidmode a simulation does not ping anybody, so bidding targets showwouldPing: true, have no price and sort last.
Failover
Failover exists because a plan that stops at the first unanswered phone wastes the call. The router treats every route below the first as a fallback, and moves down for any of these reasons:
| Result of the attempt | What it means |
|---|---|
no_answer | The target rang until its ring time-out, or the buyer's side gave up. SIP 408, 480 or 487. |
busy | The line was busy. SIP 486 or 600. |
rejected | The buyer declined the call. SIP 403 or 603. |
failed | The destination could not be reached or is not a working number, or the carrier would not place the call. |
| Skipped at the dial | The target, its buyer or its line lost its last free slot to another call between the plan being drawn and the dial. Shown as concurrency or buyer_capped. It does not count as an attempt. |
- 1
Capacity is reserved before the phone rings
A slot is taken on the target, then on its buyer, then on the target's line. Each is a single conditional update — "add one, if there is room" — so two calls cannot both take the last slot. It is all three or none: if the buyer is full, the target's slot is handed straight back.
- 2
The terms are frozen and the target is dialled
The target, buyer, price and duration threshold are written onto the call, a
call.routedevent is sent, and the call is transferred. A phone number or SIP target rings for its own ring time-out (ringTimeoutSeconds, 5–120, 20 by default). A destination that arrived in a bid rings for the campaign'sdialTimeoutSeconds(5–120, 18 by default). - 3
No answer: release, record, move on
The slots are released — exactly once, however many carrier events report the same failure — the result is written to the waterfall with the SIP answer, a
call.failoverevent is sent naming the next target, and the next route is reserved and dialled. - 4
An answer ends the walk
The call is connected from the moment the buyer's leg answers. Caps that count on connected calls move now, and
call.connectedis sent.
If the caller hangs up while a target is ringing, the walk stops, the slots are released and the waterfall says which target was ringing when they left.
The failover budget
A long plan could keep a caller listening to other people's phones ring for minutes. The failover budget is your promise to the caller about how long that can go on. It is the campaign setting failoverBudgetSeconds: 10 to 180 seconds, 45 by default, and never shorter than dialTimeoutSeconds.
- The clock starts when the call arrives. For a call a publisher won at auction, it starts when their caller actually arrives, not when the bid was claimed.
- The budget is never a reason not to ring the first target. It is only checked before the second attempt and each one after.
- It is checked before a dial, not during one. A ring that has started is allowed to finish. So the longest a caller can wait is roughly the budget plus one ring time-out.
- A route skipped at the dial costs no time and does not use up the budget.
| Time | With a 45-second budget and 20-second ring time-outs |
|---|---|
| 0 s | Attempt 1 is dialled. The budget is not consulted for a first attempt. |
| 20 s | Attempt 1 rings out. 20 is less than 45, so attempt 2 is dialled. |
| 40 s | Attempt 2 rings out. 40 is less than 45, so attempt 3 is dialled. |
| 60 s | Attempt 3 rings out. 60 is past the budget: no fourth attempt, even if the plan has more routes. The unrouted action runs. |
When every target fails: the unrouted action
A call that reaches no buyer ends with an unrouted reason. Three of them come from the plan:
| Reason | When |
|---|---|
no_targets | The plan has no active routes. |
no_eligible_target | Every route was skipped, at the judging stage or at the dial. The waterfall lists the reason for each. |
all_targets_failed | At least one target was dialled and none answered, or the failover budget ran out. |
What the caller gets then is the campaign's unrouted action, unroutedAction:
| Action | What happens | Needs |
|---|---|---|
hangup | The call ends. This is the default. | — |
message | The call is answered, your message is read out by a text-to-speech voice in US English, and the call ends. | unroutedMessage, up to 500 characters |
forward | One last transfer, to a number of yours. It is an attempt like any other in the waterfall, but it has no target, no buyer and no revenue: the call is not sold. If the forward number does not answer either, the call ends. | unroutedForwardE164, in international format |
message without a message, or forward without a number, is refused with a 400.- The action applies to five reasons:
campaign_closed,campaign_capped,no_targets,no_eligible_targetandall_targets_failed. Those are the ones where "sorry, nobody is available" or an office line is a sensible answer. - It does not apply to the other six. A blocked, anonymous or repeat caller is not forwarded to your office — that is what refusing them was for — and a workspace that is lapsed or unfunded, or a campaign that is not live, does not answer the call at all. Those callers get a busy signal.
- A call answered on the forward number still counts as unrouted, not connected, in every report. A fallback answer is not a buyer connection.
- Every unrouted call sends a
call.unroutedevent carrying thereason, itssentenceand theactionthat was taken.
Reading the plan's live indicators
The campaign's Routing plan tab and GET /campaigns/{id}/routing show the plan as it stands now, with enough on each route to tell whether it would take a call this minute.
| Indicator | Where | How to read it |
|---|---|---|
active | the route | false: the route is ignored. It is not judged and never shows as skipped. |
target.status | the route | Anything but active and every call skips it with paused. |
target.buyer.status | the route | A paused buyer takes all of its targets out of every plan at once, with buyer_paused. Nothing on the targets themselves changes. |
target.health | the route | healthy, degraded, down or unknown. For a bidding target, down means its circuit breaker has tripped and calls skip it with suppressed until it is retried. degraded is a warning only: the target is still used. For a SIP target it reflects the last reachability probe and never takes the target out of a plan by itself. |
sharePct | the route | weighted mode only: this route's share of first attempts among active routes. null in the other modes and on inactive routes. |
effectiveRevenueCents | the route | What a converted call books on this route. null for a bidding target, where the bid decides. |
usage against the caps | the target | liveCalls, hour, day, month, total and dayRevenueCents, to read beside concurrencyCap, hourlyCap and the rest. A count at its cap means the target is being skipped. Windows are UTC. |
suppressedUntil, consecutiveFailures, lastError | the target | Why a target is degraded or down, and when it will be tried again. |
counts.activeRoutes, liveCalls, today | the campaign | How many routes are switched on, how many calls are in progress, and today's calls, connected, converted, revenue and payout on the workspace's calendar day. |
checklist.warnings | the campaign | Says out loud when a plan would launch and route nothing — for example when every active target belongs to a buyer that is not active. |
Save a plan with the API
A plan is always replaced as a whole, in one transaction. That is deliberate: a plan is an ordering, and an ordering edited one row at a time passes through states nobody chose. Send every route you want to keep; a route you leave out is removed. The body is compared with what is stored, so an unchanged route keeps its id, and sending the same plan twice changes nothing.
An API key with the campaigns:write scope can read and replace a plan. It can re-rank, re-weight, switch a route off or drop it, but every target it names must already be one of your own: a key cannot make calls go anywhere they could not already go. See API keys for creating one.
curl -X PUT "https://api.buy3.io/api/v1/campaigns/6a2d1f0e-8b7c-4c55-a1d3-0e9f8a7b6c5d/routing" \
-H "Authorization: Bearer $BUY3_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"mode": "weighted",
"routes": [
{
"targetId": "a9b8c7d6-e5f4-4a3b-9c2d-1e0f9a8b7c6d",
"weight": 60
},
{
"targetId": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f",
"weight": 40
}
]
}'{
"mode": "weighted",
"failoverBudgetSeconds": 45,
"dialTimeoutSeconds": 18,
"routes": [
{
"id": "0f7a3c52-6d1e-4b8a-9c2f-5e4d3c2b1a09",
"targetId": "a9b8c7d6-e5f4-4a3b-9c2d-1e0f9a8b7c6d",
"target": {
"id": "a9b8c7d6-e5f4-4a3b-9c2d-1e0f9a8b7c6d",
"name": "Acme Health — Dallas floor",
"kind": "static",
"type": "number",
"status": "active",
"health": "healthy",
"revenueCents": 4200,
"durationThresholdSeconds": 90,
"buyer": {
"id": "f1e2d3c4-b5a6-4978-8a9b-0c1d2e3f4a5b",
"name": "Acme Health",
"status": "active"
}
},
"priority": 1,
"weight": 60,
"active": true,
"revenueOverrideCents": null,
"maxBidCents": null,
"durationThresholdSeconds": null,
"effectiveRevenueCents": 4200,
"effectiveDurationThresholdSeconds": 90,
"sharePct": 60,
"createdAt": "2026-09-18T15:02:11.000Z",
"updatedAt": "2026-09-20T09:41:30.000Z"
},
{
"id": "1a8b4d63-7e2f-4c9b-8d3a-6f5e4d3c2b10",
"targetId": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f",
"target": {
"id": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f",
"name": "BlueSky Legal — Intake",
"kind": "static",
"type": "number",
"status": "active",
"health": "unknown",
"revenueCents": 3800,
"durationThresholdSeconds": 60,
"buyer": {
"id": "e2d3c4b5-a697-4887-9bac-1d2e3f4a5b6c",
"name": "BlueSky Legal",
"status": "active"
}
},
"priority": 2,
"weight": 40,
"active": true,
"revenueOverrideCents": null,
"maxBidCents": null,
"durationThresholdSeconds": null,
"effectiveRevenueCents": 3800,
"effectiveDurationThresholdSeconds": 60,
"sharePct": 40,
"createdAt": "2026-09-20T09:41:30.000Z",
"updatedAt": "2026-09-20T09:41:30.000Z"
}
],
"saved": {
"added": 1,
"updated": 1,
"removed": 0,
"unchanged": 0
}
}curl "https://api.buy3.io/api/v1/campaigns/6a2d1f0e-8b7c-4c55-a1d3-0e9f8a7b6c5d/routing" \
-H "Authorization: Bearer $BUY3_API_KEY"| Status | Code | Why |
|---|---|---|
400 | bad_request | A route failed validation. details is keyed by row, for example routes[2].weight. A target listed twice, an archived target and a target that cannot take a tracking call are all reported this way, against routes[i].targetId. |
404 | not_found | The campaign, or a targetId, is not in your workspace. A target that belongs to somebody else and one that does not exist get the same answer. |
409 | conflict | The campaign is archived — or it is live and the new plan would leave it with no active route to an active target. Pause the campaign first if you really mean to empty its plan. |
Things that change the order for one caller
- A repeat caller under
same_target. When the campaign's repeat-caller policy issame_targetand the target that took this caller last time is eligible now, it is moved to the front of the plan, whatever the mode says. If it is not eligible, the plan stands as drawn. See Repeat and blocked callers. - A call won at auction. When a publisher pings the campaign, wins and claims the bid, the winning target is first and the auction's runners-up follow in the auction's own order, which already followed the campaign's routing mode. A runner-up whose bid has expired by the time it is needed is passed over. See How RTB works.
