API keys

Create keys for the REST API, give each one only the scopes it needs, restrict where it may be used, rotate it without downtime and read every request it made.

After this page you can issue a credential a program can act with, keep it as narrow as its job, replace it without an outage, and find out exactly why an integration started failing. An API key lets a program act as your workspace through the REST API.

A key is not a person. It has no login and cannot open the console; it holds scopes rather than a role; and the activity log records anything it changes under the key's own name rather than a colleague's. API access is on plans that include it — see pricing.

What a key looks like

Shapeb3_live_ or b3_sandbox_, then a long random secret.
Sent asAuthorization: Bearer b3_live_… on every request.
StoredOnly its SHA-256 hash, plus a display prefix such as b3_live_Zk3q. Nobody — including us — can read a key back.
ShownOnce, in the answer that created it and again each time you rotate. Lose it and you rotate; there is no recovery.
Environmentlive or sandbox. It is part of the prefix and is there so you can tell two keys apart at a glance. It does not change what a key can reach.

Creating a key

  1. 1

    Name it after what uses it

    "Nightly reconciliation job", not "key 2". The name is what you will read in the request log, in the activity log and in an alert — and what tells you whether it is safe to revoke.

  2. 2

    Choose the narrowest scopes that do the job

    A reporting script needs to read calls and reports. It does not need to change routing plans, and a key with * on a laptop is a key that can.

  3. 3

    Restrict it where you can

    An IP allowlist limits the key to addresses you name, which turns a leaked key into a useless one. A lower rate limit turns a runaway loop into a 429 rather than an incident.

    Create a key
    curl -X POST "https://api.buy3.io/api/tracking/api-keys" \
      -H "Authorization: Bearer $BUY3_SESSION_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "label": "Nightly reconciliation job",
        "scopes": ["calls:read", "reports:read"],
        "env": "live",
        "allowCidrs": ["203.0.113.0/24"],
        "rateLimitPerMinute": 30
      }'
  4. 4

    Copy it now

    key in the answer is the only time the full value exists. Put it straight into your secret store.

    201 Created
    {
      "apiKey": {
        "id": "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2190",
        "label": "Nightly reconciliation job",
        "keyPrefix": "b3_live_Zk3q",
        "scopes": ["calls:read", "reports:read"],
        "env": "live",
        "state": "active",
        "allowCidrs": ["203.0.113.0/24"],
        "rateLimitPerMinute": 30,
        "useCount": 0,
        "graceUntil": null,
        "rotatedFromId": null,
        "lastUsedAt": null,
        "lastUsedIp": null,
        "revokedAt": null,
        "createdAt": "2026-09-20T09:14:02.881Z"
      },
      "key": "b3_live_Zk3q7nR2vYdT1pLwMc0bJfHsXeQu8Xa2",
      "note": "Store this key now — it is shown only once and cannot be recovered."
    }
  5. 5

    Prove it works

    GET /api/v1/me needs no particular scope and answers { key, workspace }: the workspace the key acts as, the scopes it holds, the effectiveScopes those actually reach, its state and — if it has been rotated — when its grace window closes.

    curl -s "https://api.buy3.io/api/v1/me" \
      -H "Authorization: Bearer $BUY3_API_KEY"
  • A key must have at least one scope. A key with none would authenticate and be allowed to do nothing, which is never what somebody meant and would be discovered from a 403 in production.
  • Keys are counted against your plan. Only live keys count: a rotated key inside its grace window is on its way out and does not hold a slot, or a workspace at its limit could never rotate.
  • Creating, editing and rotating are console routes, called with a signed-in owner's or admin's session. A key cannot mint another key.

Scopes

A scope is resource:verb. There are exactly two implications and no others: * grants everything, and x:write includes x:read — a key that may change calls may obviously look at them. Nothing implies a write, and a scope on one resource says nothing about another.

ScopeLets the key
calls:readList calls and the calls in progress, read one call with its routing waterfall and recordings, and export the call log as CSV.
calls:writeReport a conversion, add tags to a call, and adjust its recorded revenue or payout.
campaigns:readRead campaigns and their routing plans.
campaigns:writeChange a campaign's routing plan, and pause or resume a campaign.
targets:readRead targets, their caps and their live counters.
targets:writeChange a target's price, caps, hours and states, pause or resume it, and reset its cap counters.
buyers:readRead buyers, their caps and their targets.
publishers:readRead publishers and the payout terms on each campaign.
numbers:readRead tracking numbers and what each is assigned to.
reports:readRun summary and time-series reports.
pings:readRead the ping log: inbound pings from publishers and outbound pings to buyers.
blocklist:writeList, add and remove blocked callers.
*Everything this API can do, including scopes added later.
Up to twenty scopes per key. A name that is not on this list is refused when you save, listing the ones that are.

Restricting where a key may be used

RestrictionAcceptsRefusal
IP allowlistUp to 50 entries: an IPv4 address or range such as 203.0.113.41 or 203.0.113.0/24, or a single IPv6 address. Empty means anywhere.403 ip_not_allowed
Rate limit1 to 1,200 requests a minute. The default is 120.429 rate_limited, with Retry-After
  • An entry that cannot be parsed is refused when you type it, not discovered when the integration goes dark: a list of only bad entries would be a key that answers 403 to everybody, its owner included, with nothing on the page to say why.
  • IPv6 takes a single address, not a range. Most hosting providers give a machine a stable one.
  • The limit is a sliding 60 seconds, so there is no burst of double the limit across a minute boundary. A refused request is not counted against the window.
  • You may slow a key down and you may not set it free: 0 is not a value a workspace can choose. The brake is the platform's as much as yours.
  • RateLimit-Policy and RateLimit come back on every answer, so a client can pace itself without counting.

Rotating and revoking

Rotation is a successor, not an edit. The old row stays, marked revoked with a grace window, and the new row points back at it. Overwriting a secret in place would destroy the answer to "what was live at three on Tuesday", which is the question a leaked key makes urgent.

Rotate with a day of grace
curl -X POST "https://api.buy3.io/api/tracking/api-keys/7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2190/rotate" \
  -H "Authorization: Bearer $BUY3_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "graceMinutes": 1440 }'
201 Created
{
  "apiKey": {
    "id": "1a2b3c4d-5e6f-4708-8192-a3b4c5d6e7f8",
    "label": "Nightly reconciliation job",
    "keyPrefix": "b3_live_8Xa2",
    "scopes": ["calls:read", "reports:read"],
    "state": "active",
    "allowCidrs": ["203.0.113.0/24"],
    "rateLimitPerMinute": 30,
    "rotatedFromId": "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2190"
  },
  "previous": {
    "id": "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2190",
    "keyPrefix": "b3_live_Zk3q",
    "state": "grace",
    "revokedAt": "2026-09-20T09:41:11.004Z",
    "graceUntil": "2026-09-21T09:41:11.004Z"
  },
  "key": "b3_live_8Xa2tLqW9nGyPzVbCkRm4dHsJ6eX1oYu",
  "graceUntil": "2026-09-21T09:41:11.004Z",
  "note": "Store this key now — it is shown only once. The previous key keeps working until 2026-09-21T09:41:11.004Z, then 401s."
}
RotateRevoke
What happensA new key is issued and the old one keeps working until the grace window closes, then answers 401.The key stops working immediately, and any grace window it had is cleared.
The successor inheritsThe label, the scopes, the owner, the IP allowlist and the rate limit. A rotation that quietly dropped the allowlist would widen access nobody asked for.
Grace window0 minutes to 7 days, default 24 hours.None.
Use it whenOn a schedule, or when somebody who knew the key leaves. Deploy the new key inside the window and nothing goes down.The key has leaked. Do this first and work out the rest afterwards.
Needs the planYes.No. "Upgrade to revoke your leaked key" is not a sentence this API says.
  • A key is in one of three states: active, grace (rotated, revoked on paper and still answering) or revoked.
  • A key already in grace cannot be rotated again — a second successor off one key would leave two live credentials descended from it and nobody sure which the integration holds. Rotate the successor instead.
  • A revoked key cannot be edited. It is a record, not a setting; widening the scopes of something on its way out is not a thing to allow.
  • Revoking a key inside its grace window cuts it off early, which is what "we rotated because it leaked" needs.
  • Every one of these is written to the activity log at high severity, with who did it.

Why a request was refused

Each refusal has a stable code at the top level of the body, for a program to branch on. They are checked in this order, cheapest and least revealing first.

StatuscodeMeans
401missing_keyNo Authorization: Bearer header at all.
401invalid_keyMalformed, not recognised, revoked with its grace window over — or a key whose workspace no longer exists.
403workspace_requiredThe key belongs to no workspace. It is a real key and this surface is not for it.
403ip_not_allowedThe key has an allowlist and the request came from outside it.
429rate_limitedOver the per-minute limit. Retry-After is the first second at which the request will succeed.
403insufficient_scopedetails lists the scopes required and the ones held.
402subscription_inactiveThe workspace's subscription is not active. On the REST API this stops reads as well as writes.
402plan_upgrade_requiredThe plan does not include call tracking, or does not include API access. details.feature says which.
404not_foundThe row is not this workspace's, or does not exist. Both answer the same, so an id cannot be used to find out which workspaces are real.
The body is always { error, code, details?, requestId }. Quote requestId to support and the exact request can be found.

The request log

Every request a key makes is a row: the method and path, the parsed query, the status, the error code, how long it took, the address it came from and the request id. Open a key to read its own log, or the API requests view for every key at once.

One row — a scope that was missing
{
  "id": "44810227",
  "apiKeyId": "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2190",
  "keyPrefix": "b3_live_Zk3q",
  "keyLabel": "Nightly reconciliation job",
  "method": "GET",
  "path": "/api/v1/reports/summary",
  "query": { "groupBy": "publisher,day", "from": "2026-09-19T05:00:00Z" },
  "status": 403,
  "errorCode": "insufficient_scope",
  "latencyMs": 11,
  "ip": "203.0.113.41",
  "userAgent": "python-requests/2.32.3",
  "requestId": "req_0f1e2d3c4b5a6978",
  "at": "2026-09-20T09:41:52.117Z"
}
  • Refusals are logged too, with their errorCode. "Why is my integration getting 401s since Tuesday?" — because it is still sending the key you rotated — is the question this log exists to answer.
  • A request that never identified a key is not logged. It could not be attributed to a workspace, so no customer could ever read the row, and writing one for every anonymous request would let anybody on the internet grow the table.
  • Bodies are never stored. They carry callers' numbers and whatever you put in a tag; the path, the query, the status and the code are what debugging actually needs. A long query string is kept up to 4 KB and then reduced to its keys.
  • Filter by status class2xx, 4xx, 5xx — by method, by error code, by a substring of the path, and by date. What somebody debugging asks is "show me the failures", not "show me the 422s".
  • useCount on the key never resets, and a successor starts at zero. lastUsedAt and lastUsedIp are written on every successful request.

Worked example: a scope change broke the nightly job

The reconciliation job has been emailing empty reports since Thursday and nobody changed the job.

  1. Open the key and filter its request log to 4xx. The rows are 403 with errorCode: insufficient_scope on /api/v1/reports/summary, starting Thursday morning.
  2. The 403 body would have carried details.required and details.held — but the log keeps only the code, so read the key's own scopes instead. It holds calls:read and somebody narrowed it from * on Thursday.
  3. Confirm in the activity log: an api_key entry at high severity, naming who edited it and showing scopes before and after.
  4. Add reports:read back. The next run succeeds, and the log shows 200s appearing against the same path.
  5. Nothing was lost: the reports were never generated, so re-run the job for the missing nights rather than looking for data to recover.

Had the rows read 401 invalid_key instead, the answer would have been the other common one — a key that was rotated and whose grace window has since closed, with the deploy that was meant to carry the successor never having happened.

Keeping a key safe

  • One key per consumer. Sharing a key between two jobs means revoking it breaks both, which is how a leaked key stays live for a fortnight.
  • Rotate on a schedule, not only after an incident. A rotation you have practised takes a deploy; a rotation you have never done takes an afternoon.
  • Set an allowlist on anything that runs from a fixed address. It is the difference between a leaked key and a leaked string.
  • Watch lastUsedAt. A key nobody has used for months is a key you can revoke, and the request log tells you whether that is true.

Next steps