API and API keys
An API key lets your own code — or a tool like Zapier or Make — read and write your Proofbell data. Keys are created under API keys in the dashboard, and they are included from Pro upwards and on every agency plan.
There is an OpenAPI document at /v1/openapi.json, and it describes no
request or response bodies. It is generated from the routes themselves, so it cannot
disagree with what the API enforces about paths, permissions and scopes — but the fields are on
this page and on the other documentation pages, not in it. There is no SDK.
The API reference renders the document as a browsable list of
every endpoint with the permission it needs, and the section below says what it holds and what it
deliberately leaves out.
Each key is limited to 1,000 requests per minute. Every response tells you how much of that allowance is left, so anything you schedule can pace itself rather than wait to be refused. The rate limits section below has the headers and the arithmetic.
Using a key
Send it as a bearer token, exactly where a signed-in session's token would go:
curl https://api.proofbell.com/v1/projects/PROJECT_ID/reports/calls?limit=50 \
-H "Authorization: Bearer pbk_your_key_here"
Keys begin pbk_. That prefix is deliberate: it means a key pasted into a public
repository is found by secret scanners, and it lets us tell a key from an expired session token so
each gets its own error rather than one confusing answer for both.
The key is shown once
We store only a hash of it. When you create a key the plaintext is displayed once and then it is gone — we cannot show it to you again and we cannot recover it. If you lose it, revoke it and create another. That is not an inconvenience we failed to design away: it is the reason somebody who gets into your account later cannot read your existing keys either.
Rotating a key
Rotate on the API keys screen gives you a new key
straight away and leaves the old one working for 24 hours. That is the window to
deploy the new one. When it passes, the old key stops and starts answering
401.
The new key inherits the old one's scope and permissions. You are not asked to choose them again, because rotation usually happens in a hurry and a replacement with more access than the key it replaced is a mistake nobody notices.
If 24 hours is not enough, Give me another day extends it once. Only once — after that the key stops at the new time whether or not your replacement is live. Over the API:
POST /v1/accounts/{accountId}/api-keys/{keyId}/rotate
POST /v1/accounts/{accountId}/api-keys/{keyId}/rotate/extend The rotate response carries the new plaintext key, and like creation it is the only time you will see it. A rotated key shows as Replaced in the list with the time it stops.
If a key has leaked, revoke it — do not rotate it. Rotation deliberately leaves the old key working for a day, which is exactly what you do not want from a credential somebody else has. Revoke stops it on the next request.
What a key is fixed to
Every key is created against exactly one of three scopes, and it cannot be changed afterwards:
- One website. Calls, numbers, routing and reports for that project only.
- One client (or your own account). Everything under it, including its websites.
- Your whole agency. Every client you have, now and in future. Only an agency owner can create one, and only from the agency-level page.
A key used outside its scope gets 404, not 403 — the same answer you
would get for something that does not exist. That is on purpose: a distinct "forbidden" would
confirm that the account or website you named is real, which for an agency is its client list
leaking one id at a time.
What a key may do
Permissions are chosen per key and cannot be widened afterwards. A key is also never more capable than the person who created it — if you do not hold a permission, a key you make cannot either.
Read versus read-write is a property of the key, not of your plan. If you want a
read-only integration, create a key holding only the …:read permissions. There is no
separate tier for it.
Three things a key can never do
It cannot manage keys or users. A key that could create another credential
could not meaningfully be revoked — revoke it and whatever it made carries on working. So
apikey:manage and user:manage are refused on a key whatever you hold.
It cannot change who you are or what you pay. Renaming a partner, restyling a
white-labelled dashboard and altering a subscription are all decisions a person makes and is
accountable for, so partner:manage, whitelabel:manage and
billing:manage are refused on a key as well.
It cannot reach a recorded conversation. Listening to a recording, downloading
one, or reading a transcript each write a log entry naming the person who did it — that
record is the whole control, and a key has no person. recording:listen,
recording:download, recording:delete and transcript:read
are therefore login-only. Ask us if this blocks something real; it is a deliberate default and
not a permanent one.
That is the whole list, and it is nine permissions: apikey:manage,
user:manage, partner:manage, whitelabel:manage,
billing:manage, recording:listen, recording:download,
recording:delete and transcript:read. Everything else you hold, a key
you create can hold — including export:data, conversion:manage and
call:manage, which are the reasons most people want a key at all.
The machine-readable description
There is an OpenAPI 3.1 document describing the API, and reading it needs no key:
curl https://api.proofbell.com/v1/openapi.json No bearer token, deliberately. A developer reads a reference before they have a key, and usually before the account exists — so a reference behind a login is a reference nobody can evaluate you on.
It is generated from the running application's own route metadata — the same
metadata the authorisation guard reads to decide whether to admit a request. So the path, the
method, the scope and the permission a route requires cannot drift from what the API actually
enforces. Every operation carries the permission it needs as
x-proofbell-permission and the scope it is fixed to as
x-proofbell-scope.
It does not describe request or response bodies. Validation here is zod rather
than decorated classes, so there is nothing for a generator to read and every schema would come
out empty — and an empty schema does not look empty, it looks like a contract. Rather than ship
that, the operations carry no body schema at all and the document says so in its own opening
paragraph. Point a client generator at it and every call will hand you back
unknown. That is worth knowing before you spend an afternoon on it, which is why it
is said twice on this page.
The error shape is described, and it is uniform across every refusal — the statuses in the errors section below are the whole set. The response fields for each feature are on the documentation page for that feature, which is where they are kept accurate.
What a key cannot reach is in it, marked rather than hidden
Operations a signed-in person can call and an API key never can carry
x-proofbell-api-key: false, with a sentence saying why. That is the "who am I"
endpoints, anything touching a recording or a transcript, and anything that could create another
credential — the two rules above, plus the endpoints that answer a question a key already knows the
answer to.
They are listed rather than left out on purpose: a 403 you were told about is
a documented answer, and one you were not looks like a bug in your own code. Somebody
would spend an evening on it before suspecting the refusal was intended.
There is no Swagger UI, no Redoc and no SDK. There IS a viewer: the API reference reads the document as the page loads and lists every endpoint with its permission, its scope and whether an API key may call it. It is ours rather than a general-purpose one, because those three facts are what the document actually carries and a standard viewer buries them. The document is also just a URL and a file — load it into whichever client, editor or test tool you already use.
It describes the customer API. Proofbell's own staff console is deliberately not in it.
Endpoints
Ids are in the path. PROJECT_ID begins proj_,
ACCOUNT_ID begins acct_, and both appear in the dashboard URL bar and in
the API keys page's scope list.
Reading calls and reports
| Endpoint | Needs |
|---|---|
GET /v1/projects/PROJECT_ID/reports/calls?limit=50 | call:read |
GET /v1/projects/PROJECT_ID/reports/summary?days=30 | report:read |
GET /v1/accounts/ACCOUNT_ID/reports/attribution/compare?days=90 | report:read |
GET /v1/accounts/ACCOUNT_ID/reports/pipeline | revenue:read |
GET /v1/projects/PROJECT_ID/settings | project:read — includes the tag key |
GET /v1/accounts/ACCOUNT_ID/entitlements | usage:read — what your plan includes |
GET /v1/accounts/ACCOUNT_ID/audit | audit:read |
Report endpoints return snake_case keys. They are raw report rows
rather than mapped objects, so talk_time_seconds and value_minor are
the field names — not their camel-case equivalents. The rest of the API is camel-case. It is
inconsistent and it is what the endpoints actually return, which is more useful to you than a
tidy claim.
Money is an integer of minor units. value_minor: 390300 is
£3,903.00. Never a decimal, and there is no currency on the figure — the account's currency
applies to all of it.
Writing revenue
The commonest reason to want a key: pushing closed deals in from your own system so revenue is attributed to the call that produced it.
| Endpoint | Needs |
|---|---|
POST /v1/accounts/ACCOUNT_ID/imports/preview | conversion:manage — dry run, matches nothing yet |
POST /v1/accounts/ACCOUNT_ID/imports | conversion:manage — commits |
Both take the CSV as a JSON string in content. Preview first, every
time — it returns the match rate and the rows it could not match, and an import that
matched 4% is nearly always a phone-number format problem rather than an attribution one. The
import documentation covers the columns and what a bare "no" in a
won/lost column means.
Exports
| Endpoint | Needs |
|---|---|
GET /v1/accounts/ACCOUNT_ID/warehouse/destinations | integration:read |
POST /v1/accounts/ACCOUNT_ID/warehouse/destinations/DEST_ID/run | export:data |
See warehouse sync for what a run produces and for the
MERGE you must load it with.
Rate limits
1,000 requests per minute, per key. Per key rather than per account: two keys on one account each get their own 1,000, and one key used by two integrations shares a single allowance between them. It applies to API keys only — a signed-in session in the dashboard is not subject to it.
It is a token bucket, so the burst capacity is also 1,000 and the sustained rate is about 16.7 requests a second. Stated plainly rather than rounded away: across an unlucky 60-second span a client can see close to 2,000 — a full bucket spent at once, plus a minute of refill.
Pace yourself against the headers
Every response to a key carries both of these, whether it succeeded or was
refused — a 401 and a 429 carry them too:
| Header | What it holds |
|---|---|
X-RateLimit-Limit | The ceiling: 1000. |
X-RateLimit-Remaining | Whole requests left in this key's bucket after the one you just made. |
Read X-RateLimit-Remaining and slow down when it gets low, rather
than waiting to be refused. The obvious thing on the other end of a key is a loop, and a loop that
watches that number never sees a 429 at all. That is why the header is on the
successful responses and not only on the refused ones: a limit you can only discover by hitting it
is a limit you cannot pace against.
There is deliberately no X-RateLimit-Reset. For a token bucket it has
no single honest meaning — there is no window that resets, and "when the bucket is full again" is
not the question a client is asking — so the two headers that are unambiguous are the two we send.
Do not go looking for it.
Being refused
Over the limit the answer is 429 with a Retry-After header in whole
seconds. It is never 0; the minimum is 1, because a header telling a loop
to retry immediately is the behaviour being limited. The message body names the ceiling and says
the limit is per key, so one line from your log is enough to tell whether the fix belongs in one
integration or in all of them.
An invalid or revoked key still spends from its own bucket. The allowance is
checked before the key is looked up, so a broken integration retrying in a tight loop gets
429 rather than the 401 that explains its real problem. Worth knowing,
because it is a confusing symptom otherwise: slow the caller down and the 401
appears.
If an integration genuinely needs a higher ceiling, ask support. It is a conversation, not something to buy — the ceiling is not graded by plan and no upgrade raises it.
Errors
| Status | What it means |
|---|---|
401 | The key is unknown, revoked or expired. All three answer the same way on purpose, so a stolen key cannot be tested against us to find out which. Check the API keys page: it shows each key as active, expired or revoked, and "never used" on a key you thought was working usually means it was pasted wrong. |
403 | The key is valid and does not hold that permission — the message names which. Permissions are fixed at creation, so the fix is a new key. |
404 | Either the thing does not exist, or it is outside the key's scope. Deliberately the same answer for both. If you are sure the id is right, check which scope the key was created at. |
400 | Something about the request is wrong, and the message says what in a sentence rather than a code. Read it — it usually names the field. |
429 |
The key has used its 1,000 requests for the minute; Retry-After gives the whole
seconds to wait. See rate limits above — including why an invalid key can answer this instead
of 401.
|
Expiry, revocation and what shows up where
A key has no expiry unless you give it one. We recommend 90 days, and we do not impose it: an expiry nobody chose is an integration that stops on a random Tuesday for a reason nobody can find.
Revoking is immediate and cannot be undone — the very next request with that key fails. The key's row is kept, so the record of what it could do and when it was used survives revocation.
Everything a key does appears in your activity log, labelled as an API key with the key's id rather than as a person. Creating and revoking a key are recorded there too. If you are wondering which of your integrations changed something, that is where to look.
What is not built
- No SDK, and no request or response bodies in the document. A generated client
will compile and return
unknownfrom every call, so this page is still where the fields are. There is a browsable API reference now; what is missing is the shapes, not the viewer. - No rate limiting while our own limiter's store is unavailable. We chose to admit requests uncounted in that window rather than refuse them, so an outage in our rate limiter never stops your integration working.
- No Zapier or Make app. A key plus their generic HTTP step is the route today.
- No webhooks for API events. Outbound webhooks fire on calls and conversions — see webhooks — and nothing notifies you about API activity.