Partner keys
Mint, scope, rotate and revoke the keys your integration authenticates with.
Partner API keys
Your partner secret key (sk_live_… / sk_test_…) authenticates every call on the provisioning API. This page covers managing those keys yourself: minting scoped keys, rotating with zero downtime, restricting by IP, and setting expiry.
All endpoints below require a key with the keys:write scope, and are bounded by the key you call with — you can never create or preserve a key more powerful than the one making the request.
Least-privilege minting
Issue additional keys scoped to exactly what a given system needs — e.g. a read-only key for a dashboard, or an invoices-only key for a till fleet.
POST /api/v1/partner/keys
Authorization: Bearer sk_live_…
{
"name": "reporting (read-only)",
"scopes": ["tenants:read", "invoices:read"],
"environment": "production",
"expiresInDays": 90,
"allowedIps": ["41.72.0.0/16", "203.0.113.10"]
}
Response (201) — the plaintext:
{
"key": "sk_live_…",
"apiKey": {
"id": 44, "type": "partner", "environment": "production",
"prefix": "sk_live_ab12", "lastFour": "9f2c",
"scopes": ["tenants:read", "invoices:read"],
"allowedIps": ["41.72.0.0/16", "203.0.113.10"],
"expiresAt": "2026-10-17T09:00:00+00:00", "revoked": false
},
"message": "Store this key now. If you lose it, GET /partner/keys/{id}/secret reads it back."
}
Guardrails (each returns 403 with an error.code):
| Rule | error.code |
|---|---|
| Requested scopes ⊄ your scopes | scope_escalation |
Sandbox key minting a production key |
environment_escalation |
| New key would outlive your key | expiry_escalation |
| Your key is IP-restricted but the new key isn't | ip_escalation |
scopes values must come from: tenants:read, tenants:write, devices:read, devices:write, keys:write, invoices:read, webhooks:read, webhooks:write.
Reading a key back
A secret you have lost does not need to be replaced. GET /partner/keys/{id}/secret returns the key's plaintext, unchanged — whatever is already using it keeps working.
GET /api/v1/partner/keys/44/secret
Authorization: Bearer sk_live_…
{
"key": "sk_live_…",
"apiKey": { "id": 44, "prefix": "sk_live_ab12", "lastFour": "9f2c", "revoked": false }
}
Prefer this to rotating. A rotation your fleet has not finished picking up is an outage waiting on the grace window to close; reading the key back changes nothing.
Holding a key's plaintext is holding the key, so this is bounded by exactly the rules that bound minting a copy of it — the calling key must hold every scope the target holds, be able to use its environment, and, if the caller is itself IP-restricted or expiring, the target must sit inside its allowlist and lifetime. A key can read itself and anything narrower, never anything broader.
| Rule | Status | error.code |
|---|---|---|
Target holds a scope you don't, or is production while you are sandbox |
403 |
insufficient_privilege |
| You are IP-restricted and the target is broader | 403 |
ip_escalation |
| The target outlives your key | 403 |
expiry_escalation |
| The key is revoked, or predates this endpoint | 404 |
secret_unavailable |
Every read is recorded against the key that asked. Revoking a key destroys the stored copy, so a revoked key answers secret_unavailable rather than handing back a string that authenticates nothing.
Rotation (zero-downtime)
Rotate a key that may be aging or compromised. We mint a fresh replacement and put the old key into a grace window (default 7 days) before it expires — deploy the new secret, then let the old one lapse.
POST /api/v1/partner/keys/{id}/rotate
Authorization: Bearer sk_live_…
Response (201):
{
"key": "sk_live_…",
"apiKey": { "id": 45, "scopes": ["…"], "expiresAt": null },
"rotatedKeyExpiresAt": "2026-07-26T09:00:00+00:00",
"message": "Rotated. The old key keeps working until it expires — deploy this new key before then."
}
- The replacement inherits the old key's scopes, environment, and IP allowlist.
- Both keys work until
rotatedKeyExpiresAt; after that only the new key does. - A key can be rotated once (
already_rotatedafterwards). You can only rotate a key whose scopes you hold. - Rotation hands you a new secret, so the minting guardrails apply to the key you rotate too: an IP-restricted key cannot rotate a key whose allowlist is broader than its own (
ip_escalation), and an expiring key cannot rotate a key that outlives it (expiry_escalation).
IP allowlisting
Set allowedIps (exact addresses or CIDR ranges, IPv4 or IPv6; up to 20 entries) when minting. A request presenting that key from any other address is rejected 403 ip_not_allowed — before scopes or account status are even evaluated, so a stolen key used off-allowlist learns nothing.
"allowedIps": ["41.72.0.0/16", "2001:db8::/32", "203.0.113.10"]
The enforced client IP is your request's source address. If you call us through a proxy/load balancer, allowlist the address we actually see (configure
TrustProxieson our side if you front us). An empty/absent allowlist means "any IP".
Expiry
Pass expiresInDays (1…3650) to make a key self-destruct. A key with an expiry cannot mint a key that outlives it, so a short-lived CI key can't bootstrap a permanent one.
List & revoke
GET /api/v1/partner/keys # your keys, masked (never the plaintext)
DELETE /api/v1/partner/keys/{id} # revoke immediately
Revoking is instant — the next request with that key gets 401. You can only revoke a key whose scopes you hold. Full schemas are in openapi.yaml.
Security notes
- Store secrets in a secrets manager, never in source. We only keep a SHA-256 hash — a lost key must be rotated/revoked, not recovered.
- Prefer several narrow keys (one per system) over one all-powerful key; blast radius on leak is then limited to that system's scopes and IPs.
- Set
expiresInDayson CI/automation keys; rotate long-lived server keys periodically. - Restrict production keys with
allowedIpsto your known egress ranges.