Developers
API reference
The conventions every buy3 API endpoint follows: the base URL, API keys and scopes, the error envelope, pagination, money, time zones, rate limits, retries, versioning and webhook signatures.
The REST API reaches the same workspace the console edits — it is the running of it, not the building of it. Replace a routing plan, pause a target, read a call's waterfall, post a conversion, pull a report: a program does those with an API key. Creating a campaign, a buyer, a target or a number stays with a signed-in person, and What a key cannot do is the full list with the reasoning. API access is available on plans that include it; see pricing.
Base URL
https://api.buy3.io/api/v1- Requests and responses are JSON, encoded as UTF-8. Send
Content-Type: application/jsonwith any body. - Use
https. Ids are UUIDs unless a page says otherwise. - A single record comes back under a key named for it —
{ "call": { … } },{ "target": { … } }— and a create answers201.
curl "https://api.buy3.io/api/v1/calls?pageSize=2" \
-H "Authorization: Bearer b3_live_your_key_here"Authentication
Send your API key as a bearer token on every request. Keys begin b3_live_ (or b3_sandbox_), are created under API keys in the console, and are shown once — see API keys.
Authorization: Bearer b3_live_your_key_hereA key acts as its workspace, never as a person. It can see and change only that workspace's records; asking for another workspace's record answers 404, the same as one that does not exist.
Scopes
- A scope is a resource and a verb:
calls:read,campaigns:write. Each endpoint in the reference lists the scope it needs. <resource>:writeincludes<resource>:read— a key that may change calls may look at them.*is every scope. Keep it for your own tooling; give an integration only what it uses.- Nothing else is implied: a scope on one resource says nothing about another.
When a key is refused
| Status | Code | Why |
|---|---|---|
401 | missing_key | No Authorization: Bearer header was sent. |
401 | invalid_key | The key is malformed, not recognised, or has been revoked. A rotated key keeps working until its grace period ends. |
403 | ip_not_allowed | The key has an IP allowlist and the request came from outside it. |
403 | insufficient_scope | The key is valid but lacks the scope. details lists the scopes required and the ones held. |
402 | subscription_inactive | The workspace's plan is not active. Unlike the console, the API refuses reads as well as writes — an integration that half-works after a plan ends fails later and harder. |
402 | plan_upgrade_required | The workspace's plan does not include call tracking, or does not include API access. Both are checked on every request; details.feature says which one is missing. |
429 | rate_limited | The key is over its per-minute limit. See Rate limits. |
Errors
Every error, on every endpoint, has the same envelope. code is a stable, machine-readable string — branch on it, never on error, which is written for people and may be reworded. Quote requestId when you contact us: it finds the exact request in our logs.
{
"error": "Validation failed",
"code": "validation_error",
"details": {
"name": "is required",
"priority": "must be a whole number"
},
"requestId": "3d6f0a52-7c1e-4b9a-8e2d-5f4a1b0c9d87"
}| Status | Code | Meaning |
|---|---|---|
400 | validation_error | The body or query failed validation. details maps each field to what was wrong with it. |
400 | bad_id | An id in the path or body is not a valid UUID. |
400 | bad_reference | The request names a record — a target, a campaign — that does not exist in your workspace. |
400 | bad_request | The request cannot be carried out as sent. error says why. |
401 | missing_key, invalid_key | See Authentication. |
402 | subscription_inactive, plan_upgrade_required | The plan is not active, or does not include the feature. |
403 | forbidden, insufficient_scope, ip_not_allowed | Authenticated, but not allowed to do this. |
404 | not_found | No such record in your workspace. A record in somebody else's answers exactly the same. |
409 | conflict, duplicate | The change clashes with the current state — a name already in use, a number already assigned. details names the field. |
422 | invalid | The record was well-formed but broke a rule of the data itself. |
429 | rate_limited | Too many requests. Wait for Retry-After seconds. |
5xx | error | Our fault. The message is deliberately general; the requestId is what identifies it. |
Pagination and sorting
Every list endpoint is paginated the same way, with page (from 1) and pageSize (25 by default, 200 at most), and answers with the same envelope.
{
"data": [
{ "id": "5f0c1c8e-2a44-4d0a-9d0f-7b3f0a6c9e12", "ref": "CL-204817" },
{ "id": "0a7d9e4b-6c21-4f8e-b3a5-1d2c3e4f5a6b", "ref": "CL-204816" }
],
"page": 1,
"pageSize": 2,
"total": 1284,
"totalPages": 642
}- Sort with
sort=<field>anddir=ascordir=desc. Each list documents the fields it can sort by; an unknown field falls back to the list's default rather than failing. - Lists are newest first unless you say otherwise.
- To walk a whole list, request pages until
pageequalstotalPages. For large pulls, theexport.csvendpoints are faster than paging.
Money
- Every amount is an integer number of cents, in a field whose name ends
Cents:payoutCents,amountCents. There are no floats and no currency strings to parse. - All amounts are in US dollars.
- Revenue and payout on a tracking call are records, not transfers — see books-only money.
- On every cap and limit,
0means unlimited, never zero.
Dates and time zones
- Timestamps are ISO 8601 in UTC:
2026-09-20T14:03:27.412Z. - Filters named
fromandtotake an ISO 8601 date or timestamp. - Anything that groups by day — reports, time series, daily caps — needs to know where midnight is. Those endpoints take
tz, an IANA zone name such asAmerica/Chicago, and default to the workspace's time zone.
Rate limits
Each key has a per-minute request limit — 120 by default — which you can change on the key. The limit is a sliding window, so a burst across a minute boundary does not get double. Every response tells you where you stand:
RateLimit-Policy: "minute";q=120;w=60
RateLimit: "minute";r=87Over the limit, the answer is 429 with the code rate_limited, a Retry-After header in seconds, and the same figures in details. A refused request does not count against the window, so waiting for Retry-After is always enough.
Retries and idempotency
| Request | Safe to retry? |
|---|---|
GET | Always. |
PUT, PATCH, DELETE | Yes. They set a state rather than add to one: replacing a routing plan twice leaves the same plan. |
POST that creates a record | Check first. Names are unique where it matters, so a repeated create answers 409 with duplicate rather than making a second copy — treat that as "it worked the first time" and fetch the record. |
POST to an action — status, test, replay | Yes. Setting a status to what it already is changes nothing. |
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. On the RTB surface, where a duplicate would sell one caller twice, an Idempotency-Key header is required on a post and honoured on a ping — see RTB ping and post.
Versioning
The version is in the path: /api/v1. Within a version we only make changes an existing integration survives:
- New endpoints, new optional parameters and new fields in a response may appear at any time. Ignore fields you do not recognise.
- New webhook events, new reason codes and new reject codes may be added. A code that has shipped is never renamed, reused or given a new meaning.
- Removing a field, changing a type or tightening validation is a breaking change, and would arrive as a new version beside this one, not inside it.
Verifying a webhook signature
Every webhook delivery carries a buy3-signature header of the form t=<unix seconds>,v1=<hex>. The v1 value is an HMAC-SHA256, keyed with the endpoint's signing secret, over the timestamp, a full stop and the raw request body:
<t>.<raw request body>- 1
Read the raw body
Verify the bytes you received, before any JSON parser touches them. Re-serialising parsed JSON changes whitespace and key order, and the signature will not match.
- 2
Recompute the HMAC
Split the header on commas, take
tandv1, and compute HMAC-SHA256 overt, a full stop and the body using the endpoint's signing secret. - 3
Compare in constant time
Use your language's constant-time comparison, not
==. - 4
Check the timestamp
Refuse a delivery whose
tis more than a few minutes old, so a captured request cannot be replayed at you later.
const crypto = require("node:crypto");
// rawBody: the request body exactly as received, before any JSON parsing.
// header: the value of the buy3-signature header.
function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
const expected = crypto
.createHmac("sha256", secret)
.update(parts.t + "." + rawBody)
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= toleranceSeconds;
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 || "");
return fresh && a.length === b.length && crypto.timingSafeEqual(a, b);
}The reference
One page per resource. Each lists its endpoints with their parameters, an example request in curl, Node.js and Python, and an example of every response.
