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.

On every request
Authorization: Bearer b3_live_your_key_here
Base URL
https://api.buy3.io/api/v1

API 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_ or b3_sandbox_ followed by at least 16 characters of letters, digits, _ and -. Anything that is not shaped like that is refused with 401 invalid_key before 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_key and 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.

  1. A bearer key is present and shaped like one — otherwise 401 missing_key or 401 invalid_key, with a WWW-Authenticate: Bearer header.
  2. The key exists and is usable: not revoked, or rotated and still inside its grace period — otherwise 401 invalid_key.
  3. 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.
  4. The request's source address is on the key's IP allowlist, if it has one — otherwise 403 ip_not_allowed.
  5. The key is under its per-minute rate limit — otherwise 429 rate_limited with Retry-After.
  6. The key holds the scope the endpoint needs — otherwise 403 insufficient_scope, with the scopes required and held in details.
  7. The workspace's subscription is active — otherwise 402 subscription_inactive.
  8. The workspace's plan includes call tracking and API access — otherwise 402 plan_upgrade_required, with the missing feature in details.

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.

ScopeWhat it allowsEndpoints it opens
calls:readList 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:writeReport 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:readRead campaigns and their routing plans.GET /campaigns, /campaigns/:id, /campaigns/:id/routing
campaigns:writeChange a campaign's routing plan, and pause or resume a campaign.PUT /campaigns/:id/routing, POST /campaigns/:id/status
targets:readRead targets, their caps and their live counters.GET /targets, /targets/:id
targets:writeChange 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:readRead buyers, their caps and their targets.GET /buyers, /buyers/:id
publishers:readRead publishers and the payout terms on each campaign.GET /publishers, /publishers/:id
numbers:readRead tracking numbers and what each is assigned to.GET /numbers
reports:readRun summary and time-series reports.GET /reports/summary, /reports/timeseries
pings:readRead the ping log: inbound pings from publishers and outbound pings to buyers.GET /pings, /pings/:id
blocklist:writeList, add and remove blocked callers.GET, POST /blocked-callers, DELETE /blocked-callers/:id
*Everything this API can do, including scopes added later.Every endpoint
Paths are relative to /api/v1. The middle column is the wording the console shows beside each tick box.
  • <resource>:write includes <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:write includes it, so the key that manages the blocklist is the key that can read it.
  • Check a key and GET /webhooks/events need 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.

On every response from a rate-limited key
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: 41
  • RateLimit-Policy and RateLimit are the structured form; RateLimit-Limit, RateLimit-Remaining and RateLimit-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, a 429, a 403 insufficient_scope and a 402 — and absent on a 401 or a 403 ip_not_allowed, which are refused before it.
  • Over the limit the answer is 429 with the code rate_limited, a Retry-After header in whole seconds, and the same two figures in details.
  • A refused request is not counted against the window, so waiting for Retry-After is 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.
429 Too Many Requests
{
  "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.

403 Forbidden
{
  "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"
}
StatusCodeWhen
400validation_errorA body or query field failed validation. details maps each field to a message, such as { "status": "status must be one of: live, paused" }.
400bad_requestThe 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.
400field_not_writableA PATCH named a field a key may not change. See Update a target.
400bad_referenceThe body names a record that does not exist. Rare: ids in a body are checked for ownership first and answer 404.
401missing_keyNo Authorization: Bearer header was sent.
401invalid_keyThe key is malformed, not recognised, revoked, or its workspace no longer exists.
402subscription_inactiveThe workspace's subscription is not active. details carries status, planId and agencyId.
402plan_upgrade_requiredThe plan does not include the feature. details carries feature and plan.
403workspace_requiredThe key does not belong to a workspace.
403ip_not_allowedThe request came from outside the key's IP allowlist.
403insufficient_scopeThe key lacks the scope. details lists the scopes required and held.
404not_foundNo 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.
409conflictThe 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.
409duplicateA uniqueness rule refused the write.
410errorA recording the carrier no longer holds.
422invalidThe record was well formed but broke a rule of the data itself.
429rate_limitedThe key is over its per-minute limit. See Rate limits.
502upstream_errorThe carrier refused or could not be reached, when fetching a recording.
5xxerrorOur 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, "" and null mean 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.

A list response
{
  "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
}
ParameterMeaning
pageFrom 1. Defaults to 1.
pageSizeDefaults to 25. The most is 200; a larger value is clamped to 200, not refused.
sortOne of the fields the list documents. An unknown field falls back to the list's default rather than failing.
dirasc 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: usageRateCents on 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 connectRate and conversionRate are fractions to four decimals (0.4273), not percentages, and are null — never 0 — when their denominator is zero. "No calls yet" is not "0%".
  • On every cap, 0 means 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.
  • from and to take an ISO 8601 instant. A bare date such as 2026-09-14 is read as midnight UTC, not midnight in your zone — send a full instant with an offset when the boundary matters.
  • When from is 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, and from must not be after to.
  • tz is an IANA zone name such as America/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 a 400, 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

GET/api/v1/me

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

API key. Send Authorization: Bearer <api key>. See Authentication.

Example request

curl "https://api.buy3.io/api/v1/me" \
  -H "Authorization: Bearer $BUY3_API_KEY"

Responses

  • 200The key and its workspace. state is active, or grace for a rotated key that answers until graceUntil and not a second longer. rateLimitPerMinute is the limit in force for this key, the default included; 0 would 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

StatusCodeWhen
401missing_keyNo Authorization: Bearer header was sent.
401invalid_keyThe key is malformed, not recognised or revoked.
402subscription_inactiveThe workspace's subscription is not active.
402plan_upgrade_requiredThe workspace's plan does not include call tracking and API access.
403ip_not_allowedThe request came from outside the key's IP allowlist.
403workspace_requiredThe key does not belong to a workspace.
429rate_limitedThe key is over its per-minute limit. Retry-After says how long to wait.