API reference
Routing plans
Read and replace a campaign's routing plan as one document, manage the tag filters that reject or re-price a call, and simulate the waterfall a call would take.
A campaign's routing plan is its ordered list of targets: who is tried, in what order, and on what terms. The API treats the plan as one document — you read it whole and you replace it whole — so a plan is never left half-edited between two requests, and one change is one line in the activity log with the whole plan before and after. The concepts are in Routing plans.
Two surfaces
| Endpoint | Surface | Credential |
|---|---|---|
GET /api/v1/campaigns/:id/routing | REST API | API key with campaigns:read |
PUT /api/v1/campaigns/:id/routing | REST API | API key with campaigns:write |
GET /api/tracking/campaigns/:id/filters | Console | Session token |
POST /api/tracking/campaigns/:id/filters | Console | Session token |
PATCH /api/tracking/campaigns/:id/filters/:filterId | Console | Session token |
DELETE /api/tracking/campaigns/:id/filters/:filterId | Console | Session token |
POST /api/tracking/campaigns/:id/simulate | Console | Session token |
The plan
| Field | Type | Meaning |
|---|---|---|
mode | string | priority, weighted or highest_bid — see Three modes. |
failoverBudgetSeconds | integer | How long the whole waterfall may take, across every attempt. It is the campaign's, shown here because it shapes the plan. |
dialTimeoutSeconds | integer | How long one target may ring before the plan moves on. |
routes | array | The routes, ordered by priority ascending, then weight descending, then oldest first. |
A route
| Field | Type | Meaning |
|---|---|---|
id | uuid | The route's own id. It survives a replace that does not change the route. |
targetId | uuid | The target this route sends calls to. It must belong to your workspace. |
target | object or null | A summary of the target as it stands now: { id, name, kind, type, status, health, revenueCents, durationThresholdSeconds, buyer }. It carries no destination and no credential, on either surface. |
priority | integer | Lower is tried first. Routes with the same priority are one tier. |
weight | integer | The route's share of calls among the routes it is level with. Only read in weighted mode. |
active | boolean | false keeps the route on the plan and out of routing. Use it to park a buyer without losing their place. |
revenueOverrideCents | integer or null | What a converted call is worth on this campaign, whatever the target says. null uses the target's own price. |
maxBidCents | integer or null | The most you will pay a bidding target on this campaign. A bid above it is clamped down; it has no meaning on a fixed-price target. |
durationThresholdSeconds | integer or null | The talk time at which a call converts on this campaign. null uses the target's own. |
effectiveRevenueCents, effectiveDurationThresholdSeconds | integer or null | The override, or the target's own — the figures actually used. effectiveRevenueCents is null for a bidding target, where the bid is the price. |
sharePct | number or null | The share of calls this route should get, to one decimal place. Only in weighted mode, and only for active routes; null otherwise. |
createdAt, updatedAt | timestamp | When the route was added and last changed. |
Three modes
mode | How the next target is chosen |
|---|---|
priority | Lowest priority first, then the next, and so on down the plan. A tie is broken by weight and then by age. This is the mode to use when you have a preferred buyer. |
weighted | Within a priority tier, calls are shared out in proportion to weight. sharePct on each route tells you what that works out at. |
highest_bid | Bidding targets are asked for a price as the call arrives and the best bid wins, above any floor. Fixed-price targets in the plan are ranked by their revenue. |
Replacing the plan
Replace the routing plan takes the whole list of routes and diffs it against what is stored, in one transaction. That has three consequences worth knowing before you write one.
- A route you leave out is removed. Read the plan, change what you mean to change, and send it all back. There is no endpoint that edits one route.
- An unchanged route keeps its
idand itsupdatedAt. The diff is by value, so re-sending a plan is not the same as rebuilding it. - Sending the same plan twice writes nothing. The answer's
savedcounts say so —{ added: 0, updated: 0, removed: 0, unchanged: n }— and no activity entry is written. That is what makes the endpoint safe to retry after a timeout.
Endpoints
- GET
/api/v1/campaigns/:id/routingRead the routing plan - PUT
/api/v1/campaigns/:id/routingReplace the routing plan - GET
/api/tracking/campaigns/:id/filtersConsole API · List tag filters - POST
/api/tracking/campaigns/:id/filtersConsole API · Add a tag filter - PATCH
/api/tracking/campaigns/:id/filters/:filterIdConsole API · Change a tag filter - DELETE
/api/tracking/campaigns/:id/filters/:filterIdConsole API · Delete a tag filter - POST
/api/tracking/campaigns/:id/simulateConsole API · Simulate a call
Read the routing plan
/api/v1/campaigns/:id/routingThe campaign's plan in full: the mode, the two timing budgets, and every route in the order it will be tried, each with a summary of its target as it stands right now. Read this before writing a plan — the object you get back is the object Replace the routing plan expects, minus the fields it computes for you.
Path parameters
| Name | Type | Description |
|---|---|---|
idRequired | uuid | The campaign's id. |
Example request
curl "https://api.buy3.io/api/v1/campaigns/6a2d1f0e-8b7c-4c55-a1d3-0e9f8a7b6c5d/routing" \
-H "Authorization: Bearer $BUY3_API_KEY"Responses
- 200The plan. An empty
routesarray means every caller on this campaign meets itsunroutedAction.
{
"mode": "priority",
"failoverBudgetSeconds": 45,
"dialTimeoutSeconds": 18,
"routes": [
{
"id": "d0c1b2a3-9e8f-4d7c-b6a5-4e3f2d1c0b9a",
"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": 1,
"active": true,
"revenueOverrideCents": null,
"maxBidCents": null,
"durationThresholdSeconds": null,
"effectiveRevenueCents": 4200,
"effectiveDurationThresholdSeconds": 90,
"sharePct": null,
"createdAt": "2026-07-03T09:00:00.000Z",
"updatedAt": "2026-09-19T09:22:10.000Z"
},
{
"id": "c9b8a7d6-5e4f-4c3b-a2d1-0f9e8d7c6b5a",
"targetId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
"target": {
"id": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
"name": "BlueSky Legal — overflow",
"kind": "static",
"type": "number",
"status": "active",
"health": "healthy",
"revenueCents": 3200,
"durationThresholdSeconds": 60,
"buyer": {
"id": "e5d4c3b2-a1f0-4e9d-8c7b-6a5f4e3d2c1b",
"name": "BlueSky Legal",
"status": "active"
}
},
"priority": 2,
"weight": 1,
"active": true,
"revenueOverrideCents": 3500,
"maxBidCents": null,
"durationThresholdSeconds": null,
"effectiveRevenueCents": 3500,
"effectiveDurationThresholdSeconds": 60,
"sharePct": null,
"createdAt": "2026-07-03T09:00:00.000Z",
"updatedAt": "2026-09-19T09:22:10.000Z"
}
]
}Errors
| Status | Code | When |
|---|---|---|
| 404 | not_found | No tracking campaign with that id in your workspace. A malformed id, an id that does not exist and another workspace's campaign all answer exactly the same, before any query runs. |
| 401 | missing_key, invalid_key | No bearer key was sent, or the key is malformed, unknown, or revoked with its grace period over. See Authentication. |
| 402 | subscription_inactive, plan_upgrade_required | The workspace's subscription is not active, or its plan does not include call tracking and API access. |
| 403 | insufficient_scope, ip_not_allowed | The key does not hold campaigns:read, or the request came from an address outside the key's allowlist. |
| 429 | rate_limited | The key is over its per-minute limit. Wait for Retry-After seconds. |
Replace the routing plan
/api/v1/campaigns/:id/routingSend the whole plan. It is diffed against what is stored and saved in one transaction, so there is no moment when the campaign has half a plan. Routes you leave out are removed. The response carries the plan as it now stands and a saved block counting what actually changed — the same plan twice answers all zeroes but unchanged, writes nothing and is not recorded, which is what makes a retry safe. A change is recorded in the activity log under the key's name, with the plan before and after.
Path parameters
| Name | Type | Description |
|---|---|---|
idRequired | uuid | The campaign's id. |
Request body
| Name | Type | Description |
|---|---|---|
modeOptional | enum | How the plan chooses between targets. Leave it out to keep the mode the campaign already has.priority, weighted, highest_bid |
routesRequired | array | The whole plan, in order. An empty array empties the plan — which is refused on a live campaign.at most 100 routes |
routes[].targetIdRequired | uuid | One of your own targets. It must not be archived, and each target may appear only once in a plan. |
routes[].priorityOptional | integer | Lower is tried first.0 to 1000 · default: the route's position in the array, counting from 1 |
routes[].weightOptional | integer | The route's share within its priority tier, in weighted mode.1 to 10000 · default 1 |
routes[].activeOptional | boolean | false keeps the route on the plan but out of routing.default true |
routes[].revenueOverrideCentsOptional | integer or null | What a converted call is worth on this campaign. null uses the target's own revenue.0 to 100000000 |
routes[].maxBidCentsOptional | integer or null | The most you will pay a bidding target on this campaign.0 to 100000000 |
routes[].durationThresholdSecondsOptional | integer or null | The talk time at which a call converts on this campaign. null uses the target's own.0 to 7200 |
Example request
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": "priority",
"routes": [
{
"targetId": "a9b8c7d6-e5f4-4a3b-9c2d-1e0f9a8b7c6d",
"priority": 1
},
{
"targetId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
"priority": 2,
"revenueOverrideCents": 3500
}
]
}'Responses
- 200The plan as it now stands, plus
saved: how many routes were added, updated, removed and left alone. - 200 (a replay)The same plan sent twice. Nothing was written and nothing was recorded.
{
"mode": "priority",
"failoverBudgetSeconds": 45,
"dialTimeoutSeconds": 18,
"routes": [
{
"id": "d0c1b2a3-9e8f-4d7c-b6a5-4e3f2d1c0b9a",
"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": 1,
"active": true,
"revenueOverrideCents": null,
"maxBidCents": null,
"durationThresholdSeconds": null,
"effectiveRevenueCents": 4200,
"effectiveDurationThresholdSeconds": 90,
"sharePct": null,
"createdAt": "2026-07-03T09:00:00.000Z",
"updatedAt": "2026-09-19T09:22:10.000Z"
},
{
"id": "c9b8a7d6-5e4f-4c3b-a2d1-0f9e8d7c6b5a",
"targetId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
"target": {
"id": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
"name": "BlueSky Legal — overflow",
"kind": "static",
"type": "number",
"status": "active",
"health": "healthy",
"revenueCents": 3200,
"durationThresholdSeconds": 60,
"buyer": {
"id": "e5d4c3b2-a1f0-4e9d-8c7b-6a5f4e3d2c1b",
"name": "BlueSky Legal",
"status": "active"
}
},
"priority": 2,
"weight": 1,
"active": true,
"revenueOverrideCents": 3500,
"maxBidCents": null,
"durationThresholdSeconds": null,
"effectiveRevenueCents": 3500,
"effectiveDurationThresholdSeconds": 60,
"sharePct": null,
"createdAt": "2026-07-03T09:00:00.000Z",
"updatedAt": "2026-09-19T09:22:10.000Z"
}
],
"saved": {
"added": 1,
"updated": 1,
"removed": 0,
"unchanged": 0
}
}Errors
| Status | Code | When |
|---|---|---|
| 400 | bad_request | "Validation failed", with details keyed by row — "routes[2].weight", "routes[0].targetId". A target named twice, an archived target, a target that cannot take a tracking call, or a number out of range each name the exact route that carries it. |
| 400 | validation_error | routes is missing, is not an array, or holds more than 100 entries. |
| 404 | not_found | No tracking campaign with that id in your workspace. A malformed id, an id that does not exist and another workspace's campaign all answer exactly the same, before any query runs. |
| 404 | not_found | Target not found — a targetId that does not exist, or belongs to another workspace. The two are indistinguishable on purpose. |
| 409 | conflict | The campaign is archived, or the plan you sent would leave a live campaign with no active route to an active target. |
| 401 | missing_key, invalid_key | No bearer key was sent, or the key is malformed, unknown, or revoked with its grace period over. See Authentication. |
| 402 | subscription_inactive, plan_upgrade_required | The workspace's subscription is not active, or its plan does not include call tracking and API access. |
| 403 | insufficient_scope, ip_not_allowed | The key does not hold campaigns:write, or the request came from an address outside the key's allowlist. |
| 429 | rate_limited | The key is over its per-minute limit. Wait for Retry-After seconds. |
Console API · List tag filters
/api/tracking/campaigns/:id/filtersConsole API. These routes take a signed-in owner's or admin's session token, not an API key, and they may change with the console. A tag filter reads one tag on the call and does one of three things: require the tag, reject the call, or adjust what the call is worth. Filters are evaluated in position order, lowest first. Each one comes back with a derived effect and a sentence describing it in plain words, so a list can be shown without re-deriving the logic. See Filters and tags.
Path parameters
| Name | Type | Description |
|---|---|---|
idRequired | uuid | The campaign's id. |
Query parameters
| Name | Type | Description |
|---|---|---|
agencyIdOptional | uuid | The workspace to act in. Only needed when you hold an owner or admin seat on more than one. |
Example request
curl "https://api.buy3.io/api/tracking/campaigns/6a2d1f0e-8b7c-4c55-a1d3-0e9f8a7b6c5d/filters" \
-H "Authorization: Bearer $BUY3_SESSION_TOKEN"Responses
- 200Every filter on the campaign, ordered by
positionand then by age.
{
"filters": [
{
"id": "7e6d5c4b-3a29-4180-9e7f-0a1b2c3d4e5f",
"campaignId": "6a2d1f0e-8b7c-4c55-a1d3-0e9f8a7b6c5d",
"position": 10,
"tagKey": "utm_source",
"comparison": "equals",
"value": "search",
"valueList": null,
"caseSensitive": false,
"adjustment": "add",
"adjustmentCents": 500,
"adjustmentFactor": null,
"active": true,
"effect": "adjust",
"sentence": "When utm_source equals search, add 500 cents to the call's revenue.",
"createdAt": "2026-08-04T14:02:00.000Z",
"updatedAt": "2026-09-01T10:30:00.000Z"
}
]
}Errors
| Status | Code | When |
|---|---|---|
| 401 | unauthorized | No session token, or one that has expired. |
| 402 | plan_upgrade_required | The workspace's plan does not include call tracking. details.feature is callTracking. |
| 403 | forbidden | You named no workspace and hold no owner or admin seat on any. |
| 404 | not_found | No tracking campaign with that id in this workspace, or you named a workspace with ?agencyId= that is not yours. |
Console API · Add a tag filter
/api/tracking/campaigns/:id/filtersConsole API. A filter says exactly one of three things, and the combination of comparison and adjustment is what decides which. Require: comparison: "required" with adjustment: "none" — the tag has to be there with that value. Reject: any other comparison with adjustment: "reject" — a call that matches is turned away. Adjust: add, multiply or override — a call that matches is re-priced. required may only pair with none, and none may only pair with required. An operand the comparison does not read is stored as null rather than kept.
Path parameters
| Name | Type | Description |
|---|---|---|
idRequired | uuid | The campaign's id. |
Query parameters
| Name | Type | Description |
|---|---|---|
agencyIdOptional | uuid | The workspace to act in. |
Request body
| Name | Type | Description |
|---|---|---|
tagKeyRequired | string | The tag to read.1 to 64 characters · letters, digits, _, . and - |
comparisonRequired | enum | How the tag is tested.equals, not_equals, contains, not_contains, begins_with, not_begins_with, greater_than, less_than, exists, not_exists, regex, not_regex, in_list, not_in_list, required |
valueOptional | string | What to compare against. Required unless the comparison is in_list, not_in_list, exists or not_exists. A number for greater_than and less_than; a pattern that compiles for regex and not_regex.up to 512 characters |
valueListOptional | array of strings | The list to test membership against. Required for in_list and not_in_list.at most 500 items · each up to 512 characters |
caseSensitiveOptional | boolean | Whether the comparison respects case. |
adjustmentOptional | enum | What happens when the filter matches.reject, none, add, multiply, override · default: none for required, reject for everything else |
adjustmentCentsOptional | integer | The amount for add (which may be negative) or override.−10000000 to 10000000 · non-zero for add · at least 0 for override |
adjustmentFactorOptional | number | The multiplier for multiply.0 to 100 · must not be 1 for multiply |
positionOptional | integer | Where in the order this filter is evaluated. Lowest first.0 to 999 |
activeOptional | boolean | false keeps the filter without applying it. |
Example request
curl -X POST "https://api.buy3.io/api/tracking/campaigns/6a2d1f0e-8b7c-4c55-a1d3-0e9f8a7b6c5d/filters" \
-H "Authorization: Bearer $BUY3_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tagKey": "utm_source",
"comparison": "equals",
"value": "search",
"adjustment": "add",
"adjustmentCents": 500,
"position": 10
}'Responses
- 201The filter that was created, with its derived
effectandsentence.
{
"filter": {
"id": "7e6d5c4b-3a29-4180-9e7f-0a1b2c3d4e5f",
"campaignId": "6a2d1f0e-8b7c-4c55-a1d3-0e9f8a7b6c5d",
"position": 10,
"tagKey": "utm_source",
"comparison": "equals",
"value": "search",
"valueList": null,
"caseSensitive": false,
"adjustment": "add",
"adjustmentCents": 500,
"adjustmentFactor": null,
"active": true,
"effect": "adjust",
"sentence": "When utm_source equals search, add 500 cents to the call's revenue.",
"createdAt": "2026-08-04T14:02:00.000Z",
"updatedAt": "2026-09-01T10:30:00.000Z"
}
}Errors
| Status | Code | When |
|---|---|---|
| 400 | validation_error | A field has the wrong type or is out of range, an operand the comparison needs is missing, a regex does not compile, or required was paired with an adjustment other than none. details names the field. |
| 409 | conflict | The campaign is archived, or it already has 100 active filters. details carries the active count and the limit. |
| 401 | unauthorized | No session token, or one that has expired. |
| 402 | subscription_inactive | The workspace's subscription is not active, and this is a write. details carries status, planId and agencyId. |
| 402 | plan_upgrade_required | The workspace's plan does not include call tracking. details.feature is callTracking. |
| 403 | forbidden | You named no workspace and hold no owner or admin seat on any. |
| 404 | not_found | No tracking campaign with that id in this workspace, or you named a workspace with ?agencyId= that is not yours. |
Console API · Change a tag filter
/api/tracking/campaigns/:id/filters/:filterIdConsole API. Change any field of the create form; none of them is required. The rules are judged on the filter as it will be, so you can change a comparison and the operand it needs in one request. Re-activating a filter on a campaign that already has its hundred active ones is refused.
Path parameters
| Name | Type | Description |
|---|---|---|
idRequired | uuid | The campaign's id. |
filterIdRequired | uuid | The filter's id. |
Query parameters
| Name | Type | Description |
|---|---|---|
agencyIdOptional | uuid | The workspace to act in. |
Request body
| Name | Type | Description |
|---|---|---|
any create fieldOptional | — | Every field of Add a tag filter.at least one field |
Example request
curl -X PATCH "https://api.buy3.io/api/tracking/campaigns/6a2d1f0e-8b7c-4c55-a1d3-0e9f8a7b6c5d/filters/7e6d5c4b-3a29-4180-9e7f-0a1b2c3d4e5f" \
-H "Authorization: Bearer $BUY3_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"adjustmentCents": 750
}'Responses
- 200The filter as it now stands.
{
"filter": {
"id": "7e6d5c4b-3a29-4180-9e7f-0a1b2c3d4e5f",
"campaignId": "6a2d1f0e-8b7c-4c55-a1d3-0e9f8a7b6c5d",
"position": 10,
"tagKey": "utm_source",
"comparison": "equals",
"value": "search",
"valueList": null,
"caseSensitive": false,
"adjustment": "add",
"adjustmentCents": 750,
"adjustmentFactor": null,
"active": true,
"effect": "adjust",
"sentence": "When utm_source equals search, add 750 cents to the call's revenue.",
"createdAt": "2026-08-04T14:02:00.000Z",
"updatedAt": "2026-09-20T19:12:03.000Z"
}
}Errors
| Status | Code | When |
|---|---|---|
| 400 | validation_error | A field has the wrong type, or the filter as it would be does not make sense. |
| 400 | bad_request | Nothing to update — the body carried no change. |
| 404 | not_found | Filter not found — no filter with that id on this campaign. |
| 409 | conflict | Re-activating the filter would take the campaign past 100 active filters. |
| 401 | unauthorized | No session token, or one that has expired. |
| 402 | subscription_inactive | The workspace's subscription is not active, and this is a write. details carries status, planId and agencyId. |
| 402 | plan_upgrade_required | The workspace's plan does not include call tracking. details.feature is callTracking. |
| 403 | forbidden | You named no workspace and hold no owner or admin seat on any. |
| 404 | not_found | No tracking campaign with that id in this workspace, or you named a workspace with ?agencyId= that is not yours. |
Console API · Delete a tag filter
/api/tracking/campaigns/:id/filters/:filterIdConsole API. Remove a filter from the campaign. Calls already settled keep the price they were settled at — a filter that added revenue does not un-add it from past calls. To stop a filter without losing it, set active: false instead.
Path parameters
| Name | Type | Description |
|---|---|---|
idRequired | uuid | The campaign's id. |
filterIdRequired | uuid | The filter's id. |
Query parameters
| Name | Type | Description |
|---|---|---|
agencyIdOptional | uuid | The workspace to act in. |
Example request
curl -X DELETE "https://api.buy3.io/api/tracking/campaigns/6a2d1f0e-8b7c-4c55-a1d3-0e9f8a7b6c5d/filters/7e6d5c4b-3a29-4180-9e7f-0a1b2c3d4e5f" \
-H "Authorization: Bearer $BUY3_SESSION_TOKEN"Responses
- 200Confirmation and the id that was removed.
{
"deleted": true,
"id": "7e6d5c4b-3a29-4180-9e7f-0a1b2c3d4e5f"
}Errors
| Status | Code | When |
|---|---|---|
| 404 | not_found | Filter not found — no filter with that id on this campaign. |
| 401 | unauthorized | No session token, or one that has expired. |
| 402 | subscription_inactive | The workspace's subscription is not active, and this is a write. details carries status, planId and agencyId. |
| 402 | plan_upgrade_required | The workspace's plan does not include call tracking. details.feature is callTracking. |
| 403 | forbidden | You named no workspace and hold no owner or admin seat on any. |
| 404 | not_found | No tracking campaign with that id in this workspace, or you named a workspace with ?agencyId= that is not yours. |
Console API · Simulate a call
/api/tracking/campaigns/:id/simulateConsole API. The waterfall a call would take, without a call. Nothing is dialled, no buyer is pinged, no counter moves, nothing is written and nothing is recorded. Unlike a real call it evaluates every gate rather than stopping at the first failure, and it resolves the plan anyway — so one request tells you both what would turn the caller away and where the call would have gone. This is the fastest way to answer "why would a Texas caller at 7pm not reach anybody". See Simulate a call.
Path parameters
| Name | Type | Description |
|---|---|---|
idRequired | uuid | The campaign's id. |
Query parameters
| Name | Type | Description |
|---|---|---|
agencyIdOptional | uuid | The workspace to act in. |
Request body
| Name | Type | Description |
|---|---|---|
callerIdOptional | string | The caller's number — E.164, eleven digits or ten. Leave it out to simulate a caller who has hidden their number.up to 32 characters |
tagsOptional | object | The tags the call would carry, so tag filters and per-target tag rules are exercised. Values are converted to strings.at most 40 keys |
publisherIdOptional | uuid | Simulate the call as if it came from this publisher. It must be one of yours. |
atOptional | date | The moment to simulate, so you can test hours of operation without waiting for them.default: now |
Example request
curl -X POST "https://api.buy3.io/api/tracking/campaigns/6a2d1f0e-8b7c-4c55-a1d3-0e9f8a7b6c5d/simulate" \
-H "Authorization: Bearer $BUY3_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"callerId": "+15555550142",
"tags": {
"utm_source": "search"
},
"at": "2026-09-20T19:15:00Z"
}'Responses
- 200
wouldRouteistrueonly when no gate failed and the plan has an eligible target.gates.failedis the first gate a real call would have ended on;gates.checkslists all eight with their verdicts.skippedhas one entry per target passed over, with its skip reason. A bidding target is never actually pinged: it appears inplanwithwouldPing: trueandrevenueCents: null.
{
"wouldRoute": true,
"reason": null,
"firstChoice": {
"linkId": "d0c1b2a3-9e8f-4d7c-b6a5-4e3f2d1c0b9a",
"targetId": "a9b8c7d6-e5f4-4a3b-9c2d-1e0f9a8b7c6d",
"targetName": "Acme Health — Dallas floor",
"targetKind": "static",
"buyerId": "f1e2d3c4-b5a6-4978-8a9b-0c1d2e3f4a5b",
"buyerName": "Acme Health",
"deliveryMode": "pstn_did",
"destination": "the number ending 0188",
"priority": 1,
"weight": 1,
"revenueCents": 4200,
"durationThresholdSeconds": 90,
"source": "static",
"wouldPing": false
},
"mode": "priority",
"plan": [
{
"linkId": "d0c1b2a3-9e8f-4d7c-b6a5-4e3f2d1c0b9a",
"targetId": "a9b8c7d6-e5f4-4a3b-9c2d-1e0f9a8b7c6d",
"targetName": "Acme Health — Dallas floor",
"targetKind": "static",
"buyerId": "f1e2d3c4-b5a6-4978-8a9b-0c1d2e3f4a5b",
"buyerName": "Acme Health",
"deliveryMode": "pstn_did",
"destination": "the number ending 0188",
"priority": 1,
"weight": 1,
"revenueCents": 4200,
"durationThresholdSeconds": 90,
"source": "static",
"wouldPing": false
}
],
"skipped": [
{
"targetId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
"targetName": "BlueSky Legal — overflow",
"buyerId": "e5d4c3b2-a1f0-4e9d-8c7b-6a5f4e3d2c1b",
"buyerName": "BlueSky Legal",
"reason": "state",
"sentence": "The caller's state is not one this target accepts."
}
],
"gates": {
"passed": [
"workspace_inactive",
"workspace_unfunded",
"campaign_not_live",
"caller_blocked",
"anonymous_blocked",
"campaign_closed",
"campaign_capped",
"repeat_blocked"
],
"failed": null,
"checks": [
{
"code": "campaign_closed",
"label": "Outside campaign hours",
"passed": true,
"sentence": null,
"owner": "campaign"
},
{
"code": "campaign_capped",
"label": "Campaign cap reached",
"passed": true,
"sentence": null,
"owner": "campaign"
}
]
},
"caller": {
"e164": "+15555550142",
"state": "TX",
"anonymous": false
},
"tags": {
"utm_source": "search"
},
"publisherId": null,
"at": "2026-09-20T19:15:00.000Z"
}Errors
| Status | Code | When |
|---|---|---|
| 400 | bad_request | tags is not an object or holds more than 40 keys. details.tags says so. |
| 404 | not_found | Publisher not found — publisherId is not one of yours. |
| 401 | unauthorized | No session token, or one that has expired. |
| 402 | subscription_inactive | The workspace's subscription is not active, and this is a write. details carries status, planId and agencyId. |
| 402 | plan_upgrade_required | The workspace's plan does not include call tracking. details.feature is callTracking. |
| 403 | forbidden | You named no workspace and hold no owner or admin seat on any. |
| 404 | not_found | No tracking campaign with that id in this workspace, or you named a workspace with ?agencyId= that is not yours. |
