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

Base URL
https://api.buy3.io/api/v1
  • Requests and responses are JSON, encoded as UTF-8. Send Content-Type: application/json with 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 answers 201.
A first request
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.

The header
Authorization: Bearer b3_live_your_key_here

A 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>:write includes <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

StatusCodeWhy
401missing_keyNo Authorization: Bearer header was sent.
401invalid_keyThe key is malformed, not recognised, or has been revoked. A rotated key keeps working until its grace period ends.
403ip_not_allowedThe key has an IP allowlist and the request came from outside it.
403insufficient_scopeThe key is valid but lacks the scope. details lists the scopes required and the ones held.
402subscription_inactiveThe 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.
402plan_upgrade_requiredThe 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.
429rate_limitedThe key is over its per-minute limit. See Rate limits.
Every request a key makes — including a refused one — is a row in that key's request log in the console.

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.

A validation error
{
  "error": "Validation failed",
  "code": "validation_error",
  "details": {
    "name": "is required",
    "priority": "must be a whole number"
  },
  "requestId": "3d6f0a52-7c1e-4b9a-8e2d-5f4a1b0c9d87"
}
StatusCodeMeaning
400validation_errorThe body or query failed validation. details maps each field to what was wrong with it.
400bad_idAn id in the path or body is not a valid UUID.
400bad_referenceThe request names a record — a target, a campaign — that does not exist in your workspace.
400bad_requestThe request cannot be carried out as sent. error says why.
401missing_key, invalid_keySee Authentication.
402subscription_inactive, plan_upgrade_requiredThe plan is not active, or does not include the feature.
403forbidden, insufficient_scope, ip_not_allowedAuthenticated, but not allowed to do this.
404not_foundNo such record in your workspace. A record in somebody else's answers exactly the same.
409conflict, duplicateThe change clashes with the current state — a name already in use, a number already assigned. details names the field.
422invalidThe record was well-formed but broke a rule of the data itself.
429rate_limitedToo many requests. Wait for Retry-After seconds.
5xxerrorOur 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.

A list response
{
  "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> and dir=asc or dir=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 page equals totalPages. For large pulls, the export.csv endpoints 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, 0 means unlimited, never zero.

Dates and time zones

  • Timestamps are ISO 8601 in UTC: 2026-09-20T14:03:27.412Z.
  • Filters named from and to take 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 as America/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:

Response headers
RateLimit-Policy: "minute";q=120;w=60
RateLimit: "minute";r=87

Over 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

RequestSafe to retry?
GETAlways.
PUT, PATCH, DELETEYes. They set a state rather than add to one: replacing a routing plan twice leaves the same plan.
POST that creates a recordCheck 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, replayYes. 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:

What is signed
<t>.<raw request body>
  1. 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. 2

    Recompute the HMAC

    Split the header on commas, take t and v1, and compute HMAC-SHA256 over t, a full stop and the body using the endpoint's signing secret.

  3. 3

    Compare in constant time

    Use your language's constant-time comparison, not ==.

  4. 4

    Check the timestamp

    Refuse a delivery whose t is 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.