API reference
Authentication
How a request to the REST API proves which workspace it acts for — API keys, scopes, IP allowlists, rate limits and environments — and the conventions every endpoint shares: errors, pagination, money and time zones.
After this page you can make an authenticated request, give a key exactly the reach an integration needs, and handle every refusal the API can answer with. Every request to /api/v1 carries an API key as a bearer token. The key identifies the workspace, so no workspace id appears in any path, and a key can never reach outside the workspace that made it. The REST API is available on plans that include it; see pricing.
Authorization: Bearer b3_live_your_key_herehttps://api.buy3.io/api/v1API keys
A key exists so that a program can act as your workspace without a person's password. You create one under API keys in the console: give it a label, tick the scopes it needs, choose its environment, and optionally set an IP allowlist and a per-minute limit. The walkthrough is in API keys, and the routes behind that screen are in the API keys reference.
- A key reads
b3_live_orb3_sandbox_followed by at least 16 characters of letters, digits,_and-. Anything that is not shaped like that is refused with401 invalid_keybefore any lookup. - The full key is shown once, in the response that created it. Only a hash is stored, so a lost key cannot be recovered — rotate it instead.
- Rotation makes a successor. The old key is revoked at once and keeps answering until its grace period ends, so you can deploy the new key without an outage. Check a key says
state: "grace"and when the grace ends. - A key is not a user. It has no login and no role, and every change it makes is written to the activity log with the actor kind
api_keyand the key's prefix.
What is checked, in order
Every request passes the same gate before its handler runs. The order is deliberate — cheapest and least revealing first — and it tells you which refusal you will meet when several apply.
- A bearer key is present and shaped like one — otherwise
401 missing_keyor401 invalid_key, with aWWW-Authenticate: Bearerheader. - The key exists and is usable: not revoked, or rotated and still inside its grace period — otherwise
401 invalid_key. - The key belongs to a workspace — otherwise
403 workspace_required. Every key made in the console does; you only meet this with a platform key. - The request's source address is on the key's IP allowlist, if it has one — otherwise
403 ip_not_allowed. - The key is under its per-minute rate limit — otherwise
429 rate_limitedwithRetry-After. - The key holds the scope the endpoint needs — otherwise
403 insufficient_scope, with the scopesrequiredandheldindetails. - The workspace's subscription is active — otherwise
402 subscription_inactive. - The workspace's plan includes call tracking and API access — otherwise
402 plan_upgrade_required, with the missingfeatureindetails.
Scopes
A scope is a resource and a verb. Give an integration only the scopes it uses: a leaked reporting key should not be able to change a routing plan. Each endpoint in this reference names the scope it needs.
| Scope | What it allows | Endpoints it opens |
|---|---|---|
calls:read | List calls and the calls in progress, read one call with its routing waterfall and recordings, and export the call log as CSV. | GET /calls, /calls/live, /calls/export.csv, /calls/:id, /calls/:id/recordings/:rid/audio |
calls:write | Report a conversion, add tags to a call, and adjust its recorded revenue or payout. | POST /calls/:id/conversion, /calls/:id/tags, /calls/:id/adjust |
campaigns:read | Read campaigns and their routing plans. | GET /campaigns, /campaigns/:id, /campaigns/:id/routing |
campaigns:write | Change a campaign's routing plan, and pause or resume a campaign. | PUT /campaigns/:id/routing, POST /campaigns/:id/status |
targets:read | Read targets, their caps and their live counters. | GET /targets, /targets/:id |
targets:write | Change a target's price, caps, hours and states, pause or resume it, and reset its cap counters. | PATCH /targets/:id, POST /targets/:id/status, /targets/:id/reset-caps |
buyers:read | Read buyers, their caps and their targets. | GET /buyers, /buyers/:id |
publishers:read | Read publishers and the payout terms on each campaign. | GET /publishers, /publishers/:id |
numbers:read | Read tracking numbers and what each is assigned to. | GET /numbers |
reports:read | Run summary and time-series reports. | GET /reports/summary, /reports/timeseries |
pings:read | Read the ping log: inbound pings from publishers and outbound pings to buyers. | GET /pings, /pings/:id |
blocklist:write | List, add and remove blocked callers. | GET, POST /blocked-callers, DELETE /blocked-callers/:id |
* | Everything this API can do, including scopes added later. | Every endpoint |
/api/v1. The middle column is the wording the console shows beside each tick box.<resource>:writeincludes<resource>:read. A key that may change calls may look at them.*is every scope. Keep it for your own tooling.- Nothing else is implied: no scope grants a write it does not name, and a scope on one resource says nothing about another.
- Where an endpoint lists several scopes, any one of them opens it.
- Listing blocked callers asks for
blocklist:read. That is not a scope you can tick —blocklist:writeincludes it, so the key that manages the blocklist is the key that can read it. - Check a key and
GET /webhooks/eventsneed a valid key and no scope at all.
What a key cannot do
Some acts stay with a signed-in person, because they have consequences a program should not trigger on its own. There is no /api/v1 route to create or edit a campaign, a buyer, a target or a number; to change where a target's calls go; to launch, archive or restore anything; to read tag filters, number pools or a lane's Ringba-compatible URL; to run a target test; or to export pings and reports. The console does those through its own session-authenticated routes, which each resource page lists under Console API.
IP allowlists
An allowlist limits a key to the addresses your servers call from, so a key copied off a laptop is useless anywhere else. It is optional, and set per key in the console.
- Each entry is an IPv4 address (
203.0.113.9), an IPv4 range in CIDR form (203.0.113.0/24) or a single IPv6 address. IPv6 entries match exactly; IPv6 ranges are not supported. A key holds up to 50 entries. - An empty list means any address.
- An entry that cannot be parsed is refused when you save it, not discovered when the integration goes dark.
- A request from outside the list answers
403 ip_not_allowed. It is still written to the key's request log, so you can see who tried. - A rotated key inherits its predecessor's allowlist.
Rate limits
Each key has a per-minute request limit. It exists to stop a runaway script from turning into an incident, not to meter you: nothing is billed on it. The default is 120 requests a minute, and you can set any whole number from 1 to 1200 on the key. The window slides, so a burst across a minute boundary does not get double.
X-Request-Id: 5d1c0d2e-8a4f-4c0b-9e1d-2f3a4b5c6d7e
RateLimit-Policy: "minute";q=120;w=60
RateLimit: "minute";r=57
RateLimit-Limit: 120
RateLimit-Remaining: 57
RateLimit-Reset: 41RateLimit-PolicyandRateLimitare the structured form;RateLimit-Limit,RateLimit-RemainingandRateLimit-Reset(seconds) say the same thing for clients that read the older three headers.- The headers are set once the limiter has run, so they are present on a
200, a429, a403 insufficient_scopeand a402— and absent on a401or a403 ip_not_allowed, which are refused before it. - Over the limit the answer is
429with the coderate_limited, aRetry-Afterheader in whole seconds, and the same two figures indetails. - A refused request is not counted against the window, so waiting for
Retry-Afteris always enough. - The count is kept in memory on each API server. Treat the figure as a ceiling to stay under, not as a precise meter to pace a job against.
{
"error": "This key is limited to 120 requests a minute — retry in 7s",
"code": "rate_limited",
"details": { "limit": 120, "retryAfter": 7 },
"requestId": "5d1c0d2e-8a4f-4c0b-9e1d-2f3a4b5c6d7e"
}async function call(url, options, attempts = 5) {
for (let attempt = 1; ; attempt += 1) {
const res = await fetch(url, options);
// Retry a 429 after the wait the server asks for, and a 5xx with a growing delay.
const retryable = res.status === 429 || res.status >= 500;
if (!retryable || attempt === attempts) return res;
const wait = Number(res.headers.get("retry-after")) || 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
}
}Environments
A key is made as live or sandbox. The choice is fixed for the life of the key, shows in its prefix (b3_live_…, b3_sandbox_…), is reported by Check a key as env, and is inherited when the key is rotated. It is there so that you can tell your staging credentials from your production ones in a key list and in the request log.
Errors
Every error has the same envelope. Branch on code, which is stable; never on error, which is written for people and may be reworded. details is present only when the refusal carries something to act on. requestId is also the X-Request-Id response header and the id on the key's request log row, so quote it when you contact us.
{
"error": "This key needs the calls:write scope",
"code": "insufficient_scope",
"details": {
"required": ["calls:write"],
"held": ["calls:read", "reports:read"]
},
"requestId": "5d1c0d2e-8a4f-4c0b-9e1d-2f3a4b5c6d7e"
}| Status | Code | When |
|---|---|---|
400 | validation_error | A body or query field failed validation. details maps each field to a message, such as { "status": "status must be one of: live, paused" }. |
400 | bad_request | The request cannot be carried out as sent: a rule that spans fields, a bad date range, an unknown time zone, nothing to update. details names the field when there is one. |
400 | field_not_writable | A PATCH named a field a key may not change. See Update a target. |
400 | bad_reference | The body names a record that does not exist. Rare: ids in a body are checked for ownership first and answer 404. |
401 | missing_key | No Authorization: Bearer header was sent. |
401 | invalid_key | The key is malformed, not recognised, revoked, or its workspace no longer exists. |
402 | subscription_inactive | The workspace's subscription is not active. details carries status, planId and agencyId. |
402 | plan_upgrade_required | The plan does not include the feature. details carries feature and plan. |
403 | workspace_required | The key does not belong to a workspace. |
403 | ip_not_allowed | The request came from outside the key's IP allowlist. |
403 | insufficient_scope | The key lacks the scope. details lists the scopes required and held. |
404 | not_found | No such record in your workspace. A record in somebody else's workspace, and a path id that is not a valid UUID, answer exactly the same — the API never confirms that an id exists elsewhere. |
409 | conflict | The request is well formed and the current state refuses it: an archived campaign, a call still in progress. details often says what to do next. |
409 | duplicate | A uniqueness rule refused the write. |
410 | error | A recording the carrier no longer holds. |
422 | invalid | The record was well formed but broke a rule of the data itself. |
429 | rate_limited | The key is over its per-minute limit. See Rate limits. |
502 | upstream_error | The carrier refused or could not be reached, when fetching a recording. |
5xx | error | Our fault. The message is deliberately general; the requestId is what identifies it. |
- Unknown body and query fields are ignored, not refused. The one exception is Update a target, which refuses a field a key may not write rather than dropping it silently.
- In a body,
""andnullmean the same thing: "clear this" on a field that may be empty, "not sent" on one that may not. Adjust a call's books is the one endpoint that tells them apart, and says so. - Bodies are JSON, at most 2 MB.
Pagination and sorting
Every list answers the same envelope, so one paging loop works everywhere.
{
"data": [
{ "id": "0b9f6c1e-52a4-4a0e-9f0a-3d1c7e8b2a11", "ref": "CA-482137" },
{ "id": "7e1d0c9b-3a2f-4b6e-8d5c-4f3a2b1c0d9e", "ref": "CA-482136" }
],
"page": 1,
"pageSize": 2,
"total": 137,
"totalPages": 69
}| Parameter | Meaning |
|---|---|
page | From 1. Defaults to 1. |
pageSize | Defaults to 25. The most is 200; a larger value is clamped to 200, not refused. |
sort | One of the fields the list documents. An unknown field falls back to the list's default rather than failing. |
dir | asc or desc. Defaults to desc, so lists are newest first. |
totalPages is never below 1, even for an empty list. Some lists add fields beside the envelope — stats, from, to, timezone — and each says which.To walk a whole list, request pages until page equals totalPages. For a large pull of calls, Export calls as CSV is faster than paging.
Money
- Every amount is an integer number of US cents, in a field whose name ends
Cents:revenueCents,payoutCents. There are no floats and no currency strings to parse. - The exception is a rate:
usageRateCentson a call is cents per minute with two decimal places, because a per-minute rate can be a fraction of a cent. Your plan's per-minute rate is on the pricing page. - Rates such as
connectRateandconversionRateare fractions to four decimals (0.4273), not percentages, and arenull— never0— when their denominator is zero. "No calls yet" is not "0%". - On every cap,
0means unlimited, never zero. - Revenue and payout on a tracking call are records, not transfers: no wallet moves when they change. See books-only money.
Dates and time zones
- Every timestamp in a response is ISO 8601 in UTC:
2026-09-20T14:03:11.000Z. fromandtotake an ISO 8601 instant. A bare date such as2026-09-14is read as midnight UTC, not midnight in your zone — send a full instant with an offset when the boundary matters.- When
fromis left out, the window is the last 7 calendar days in the request's time zone, up to now. A window may span at most 366 days, andfrommust not be afterto. tzis an IANA zone name such asAmerica/Chicago. It decides where a "day" begins in a report and the local timestamps in an export, and defaults to the workspace's time zone. An unrecognised zone is a400, never a silent fall back to UTC.- "Today" on a campaign, buyer or target (
today) is the workspace's calendar day. - Cap windows roll on UTC — the UTC clock hour, the UTC date, the first of the UTC month — whatever the workspace's zone. A daily cap therefore resets at midnight UTC.
- An hours schedule carries its own zone in
hoursSchedule.tz.
Retries and idempotency
Every write on /api/v1 is safe to repeat, because each one sets a state rather than adding to one. A conversion or an adjustment sets the books; tags merge; a status that is already so answers changed: false; a routing plan is replaced whole, and the same plan twice writes nothing; blocking a caller who is already blocked answers the existing row with created: false. An Idempotency-Key header is accepted and ignored. What a replay does add is a line of history: an activity entry, and for an adjustment one more entry in the call's adjustments.
Retry on a network error, a 429 or a 5xx, with a growing delay. Do not retry any other 4xx: the same request will get the same answer.
Endpoints
Check a key
/api/v1/meThe first request every integration should make: which key is this, what may it do, how fast, and for which workspace. It needs a valid key and no scope, so it works for any key you hold. effectiveScopes spells out the two implications the gate applies (*, and write includes read) against the scopes this API asks for, so you can test for calls:read without re-implementing them. The key's allowlist and the workspace's other keys are never returned. Nothing is written except the request log row every request leaves.
Example request
curl "https://api.buy3.io/api/v1/me" \
-H "Authorization: Bearer $BUY3_API_KEY"Responses
- 200The key and its workspace.
stateisactive, orgracefor a rotated key that answers untilgraceUntiland not a second longer.rateLimitPerMinuteis the limit in force for this key, the default included;0would mean unlimited.
{
"key": {
"id": "5b1c9a70-2e4d-4f3a-9c8b-7d6e5f4a3b2c",
"label": "Warehouse sync",
"prefix": "b3_live_Qx7m",
"env": "live",
"scopes": [
"calls:write",
"reports:read"
],
"effectiveScopes": [
"calls:read",
"calls:write",
"reports:read"
],
"state": "active",
"graceUntil": null,
"rateLimitPerMinute": 120,
"createdAt": "2026-09-01T09:30:00.000Z"
},
"workspace": {
"id": "8d6f2b1c-3e4a-4f5b-9c6d-7e8f9a0b1c2d",
"name": "Example Calls Ltd",
"timezone": "America/Chicago"
}
}Errors
| Status | Code | When |
|---|---|---|
| 401 | missing_key | No Authorization: Bearer header was sent. |
| 401 | invalid_key | The key is malformed, not recognised or revoked. |
| 402 | subscription_inactive | The workspace's subscription is not active. |
| 402 | plan_upgrade_required | The workspace's plan does not include call tracking and API access. |
| 403 | ip_not_allowed | The request came from outside the key's IP allowlist. |
| 403 | workspace_required | The key does not belong to a workspace. |
| 429 | rate_limited | The key is over its per-minute limit. Retry-After says how long to wait. |
