Authentication
Workspace API keys, how to rotate them, and what a rejected request looks like.
Authentication
Every request to the Product API carries a workspace API key as a bearer token.
Authorization: Bearer cap_a1b2c3d4e5f6.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
A key is two parts joined by a dot: a public cap_-prefixed identifier and a
48-character secret. Only the prefix is stored in the clear — the secret is
hashed, so we can look a key up quickly without being able to read it back.
Keys are workspace-scoped
A key belongs to exactly one workspace and can never read or write another's data. There is no account-level or cross-workspace key. If you operate several workspaces, you hold several keys.
Scopes
Each key carries a set of scopes, checked on every request:
| Scope | Grants |
|---|---|
contacts:read |
GET /workspace, GET /contacts |
contacts:write |
POST /contacts |
messages:write |
POST /messages, POST /media |
messages:read |
GET /messages, GET /messages/{id} |
media:read |
GET /media/{id}, GET /media/{id}/download |
groups:read |
GET /groups, POST /groups/sync |
groups:send |
POST /groups/{id}/messages |
templates:read |
GET /templates, GET /templates/{id}, GET /templates/{id}/preview |
templates:write |
POST /templates, PUT /templates/{id}, DELETE /templates/{id} |
campaigns:read |
GET /campaigns, GET /campaigns/{id}, GET /campaigns/{id}/recipients, POST /campaigns/audience/preview |
campaigns:write |
POST /campaigns, PUT /campaigns/{id}, DELETE /campaigns/{id} |
campaigns:send |
POST /campaigns/{id}/schedule, /pause, /resume, /cancel |
Grant only what an integration needs. A reporting job that never sends should
hold contacts:read alone — then a leaked key cannot message your customers.
Sending and reading inbound files are separate on purpose: a key that only
delivers notifications never gains the ability to pull your customers' photos and
documents back out.
Messaging a group is separate from messages:write for the same reason. One
call to a group reaches every member at once, and those members never opted in
to anything with you — a key issued to send order updates should not silently be
able to post to a group of five hundred. See Groups.
campaigns:send is split from campaigns:write on the same principle, one
step further: scheduling or resuming a campaign fires messages at an entire
audience. A CMS integration that keeps drafts in sync holds campaigns:write
and can never launch one; the thing that launches holds campaigns:send and is
the credential you guard hardest.
Creating a key without choosing scopes grants all twelve.
Scopes are stamped when the key is issued. A key created before a scope existed does not gain it later — to call newer endpoints (templates, campaigns), issue a fresh key.
Creating and revoking
Dashboard → Developers → API keys.
The plaintext key is displayed once, at creation. We store only a hash, so "show me my key again" is not a feature we can build — recovery is always create a new one, revoke the old one.
Revoking takes effect immediately: the next request gets 401. A revoked key
can then be removed from the list entirely. Both actions are recorded in the
workspace audit log.
Rotate by creating the new key first, deploying it, then revoking the old one. That order means no window where neither key works.
Check a key works
Read the workspace the key belongs to. It is the cheapest way to confirm a new
key is live before you build against it, and it needs only contacts:read.
GET /api/v1/workspace
{
"data": {
"id": "3f1c0e2a-6d3a-4c1b-9f7e-2a1d8c0b5e33",
"name": "Karachi Electronics",
"slug": "karachi-electronics",
"status": "active"
}
}
A 200 tells you two things at once: the key is valid, and its workspace is
active. You will only ever see "status": "active" here — a workspace that is
not active is refused at authentication, so it returns the 403 below on this
call too, rather than a 200 carrying some other status.
Rejected requests
| Condition | Status | Body |
|---|---|---|
| Missing, malformed, unknown or revoked key | 401 |
{"message":"A valid API bearer token is required."} |
| Workspace inactive | 403 |
{"message":"The API key workspace is unavailable."} |
| Valid key, scope not granted | 403 |
{"message":"The API key does not have the required scope."} |
An unknown key and a revoked key return the identical 401. Distinguishing them
would tell an attacker which guesses were once valid.
Rate limits
| Limiter | Default | Applies to |
|---|---|---|
| Per key | 60 requests/minute | Every authenticated request |
| Per IP, failed auth | 120/minute | Requests with an invalid bearer |
Both windows are 60 seconds.
Exceeding the per-key limit returns:
{ "message": "The API key rate limit has been reached." }
Exceeding the failed-auth limit returns a different message, because it is a different condition — too many bad credentials from one address, not too much traffic from one key:
{ "message": "Too many invalid API bearer attempts." }
There is no Retry-After header today. Back off on your own schedule —
exponential with jitter, starting around a second — rather than waiting for a
header to tell you when.
Note that a request rejected for a missing scope still consumes per-key quota: the key was valid, the permission was not.
Keeping the key secret
- Server-side only. A key in browser JavaScript or a mobile binary is a published key, whatever the app does to hide it.
- Not in source control. Use environment variables or a secret manager.
- One key per integration. Separate keys for your CRM, your order system and your staging environment mean you can revoke one without taking down the others — and the audit log tells you which system did what.
We never log the bearer token or raw request bodies.
Webhook secrets are different
The signing secret on a webhook endpoint is not an API key and is not interchangeable with one. It never authenticates a request to us — it lets you verify that a request came from us. See Signature verification.