API keys
Every key belongs to one project and carries one environment. Beyond that you choose three things: what it can reach, where it can be used from, and how hard it may be used.
Two kinds
Publishable keys (pk_<env>_…) ship inside your app bundle. They are safe there: they carry a fixed, narrow scope set and are rejected outright on every organisation-admin endpoint. Secret keys (sk_<env>_…) are for your server. Treat one like a password.
Access presets
Secret keys choose an access level at creation. Pick a preset, or open Advanced to select individual scopes.
| Preset | Grants | Reach for it when |
|---|---|---|
| Read-only | Every PRODUCT read scope: analytics, events, errors, logs, profiles, campaigns, links, configuration. | An AI agent, a reporting job, a dashboard you built yourself. |
| Read & write | Everything above, plus creating and editing product data and sending campaigns. | A backend integration that manages content on your behalf. |
| Full access | Every scope, including ones added in future releases. Identical to every secret key issued before scopes existed. | Your own server, where the key is already trusted with everything. |
api-keys:read, api-keys:write, members:write, billing:write, gdpr:write, orgs:write, projects:write, providers:write, auth-service:write. The clearest case is api-keys:write: a key that can mint keys can mint itself a full-access one, so a “read & write” preset containing it would be full access wearing a different label.A scoped key fails closed
If an endpoint has not declared which scope it needs, a key with a restricted scope set cannot call it — it returns 403 rather than being allowed through. This is deliberate and it is the direction that matters: when we add an endpoint and forget to label it, your read-only key stays read-only instead of quietly gaining access to it.
Full-access keys are unaffected, which is every key issued before this shipped. If you hit a 403 on something you believe your key should reach, tell us and we will label the endpoint.
GET /orgs/{orgId}/api-keys/scopes returns the complete catalogue: every scope, what it grants, and exactly what each preset expands to. It needs only me:read, which both presets grant and every publishable key carries — so a key can always ask what it is allowed to do. The response is the same for every organisation; it describes the product, not your data.
IP allowlists
A secret key can be restricted to a set of addresses. Bare addresses or CIDR blocks, IPv4 or IPv6, one per line:
203.0.113.7
198.51.100.0/24
2001:db8::/32An empty list means no restriction — and it is the only way to express that. We reject 0.0.0.0/0 because it looks like a restriction while allowing everything, which is worse than having none: you would believe the key was locked down.
https://api.sendoracloud.com.Allowlists are editable after the key is issued. Your office IP changing should not force you to rotate a credential and redeploy.
Rate limits
The platform limit is 200 requests per minute per IP address, and that has not changed. On top of it you can give an individual secret key a smaller budget of its own.
By default a key has no limit of its own and is bounded only by the platform limit. Set one when you want a specific integration bounded — an AI agent, a backfill script, anything whose appetite you would rather cap than discover.
Publishable keys cannot carry a per-key budget, deliberately: every install of your app presents the same publishable key, so a shared counter would throttle your entire user base at once. Those requests are bounded per device instead.
Exceeding either limit returns 429 with retryAfterSeconds, and every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. Our SDKs map the 429 to a rate_limited error marked retryable, so a well-behaved client backs off without extra work.
Creating a scoped key
curl -X POST https://api.sendoracloud.com/api/v1/orgs/{{ORG_ID}}/api-keys \
-H "X-API-Key: {{SECRET_KEY}}" \
-H "Content-Type: application/json" \
-d '{
"name": "analytics-agent",
"kind": "secret",
"projectId": "{{PROJECT_ID}}",
"scopePreset": "read_only",
"allowedIps": ["203.0.113.0/24"],
"rateLimitPerMinute": 60
}'The plaintext key is returned once and never again. Pass either scopePreset or an explicit scopes array — not both; we reject the pair rather than guessing which you meant. An unrecognised scope is also rejected, because a key carrying a scope that does not exist would authenticate perfectly and then be refused everywhere, with nothing pointing at the typo.
What cannot change afterwards
Scopes, kind, environment and project assignment are fixed once a key is issued — changing them would silently change what the key can do everywhere it is already deployed. The name, the IP allowlist and the rate limit are all editable, because those describe how a key is operated rather than what it is for. To change a key’s access, mint a new one and revoke the old.