Authentication & limits
Bearer tokens, scopes, what a key can see, rate limits and idempotent retries.
Bearer token
Every request carries a bearer token:
Authorization: Bearer sk_live_9f3c…
API keys are created in the SnapKey dashboard under Developer → API keys. The full token is
shown once, at creation — store it in your secret manager; SnapKey keeps only a hash and the
sk_live_9f3c prefix. A key can be revoked at any time from the same screen; a revoked or expired
key answers 401 unauthenticated.
Scopes
A key carries an explicit list of scopes. A request to an endpoint whose scope the key does not hold
answers 403 insufficient_scope.
| Scope | Grants |
|---|---|
catalog:read |
GET /locks, GET /locks/{id}, GET /security_groups |
people:read |
GET /people, GET /people/{id} |
people:write |
POST, PUT, DELETE on /people — and everything people:read grants |
keys:read |
GET /keys, GET /keys/{id}, GET /invitations, GET /invitations/{id} |
keys:write |
POST /keys, PATCH, DELETE on /keys/{id}, POST /invitations/{id}/resend, DELETE /invitations/{id} — and everything keys:read grants |
locks:control |
POST /locks/{id}/unlock, GET /locks/{id}/commands/{command_id} — only for the locks listed on the key |
events:read |
GET /events |
webhooks:manage |
all /webhooks endpoints |
A read endpoint accepts either scope: GET /people is served for a key holding people:read or
people:write.
What a key can see
An API key belongs to exactly one location. Every list, lookup and write is limited to that
location and its descendants — never the account root, never a sibling department. A row that
exists elsewhere in the account is not "forbidden", it is simply 404 not_found.
Reseller (platform-mode) accounts are not supported in v1: their keys answer 403 not_available on
every endpoint.
Key expiry and rotation
Every key carries an expiry date: 1 year by default, up to 2 years if the person creating it
sets one further out. There is no non-expiring key — plan to rotate before expires_at, which
the API keys screen in the dashboard shows for every key.
Rotate by creating the next key in the dashboard, moving your secret manager over to it, and then revoking the old one.
Rate limits
600 requests per minute per API key. A request that arrives without a bearer token is counted against a shared per-IP bucket of the same size instead.
Every response from an authenticated endpoint carries:
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
requests allowed per minute (600) |
X-RateLimit-Remaining |
requests left in the current window |
Exceeding a limit answers 429 rate_limited with a Retry-After header holding the number of
seconds to wait. Two endpoints are limited harder, because each one makes SnapKey act on the outside
world:
| Endpoint | Limit |
|---|---|
POST /keys |
30 per minute, per API key — every call sends an SMS or e-mail |
POST /webhooks/{id}/ping |
10 per minute, per API key |
On POST /keys, X-RateLimit-Limit shows the limiter closest to exhaustion on that route, so it
can show 30 rather than the account-wide 600. The POST /keys limit is counted before validation
and scope checks — a rejected request still consumes one.
Requests that fail to authenticate are counted separately: 30 failed authentications per minute,
per IP. Once an IP has produced 30 failed authentications in a minute, every request from that IP
is answered 429 until the window rolls — including requests with a valid key. Retry-After tells
you how long. If you share an egress IP with other tenants or with misconfigured clients, expect
this and back off.
Idempotency
POST /people, POST /keys and POST /webhooks accept an optional Idempotency-Key request
header — 1–64 characters of A–Z a–z 0–9 _ -. Send one per logical operation and reuse it when you
retry after a timeout.
- The first request runs normally; its status and body are stored for 24 hours, per API key.
- A retry with the same key and the same body returns the stored response verbatim, with
Idempotent-Replayed: true. That is the answer to "did my retry create a duplicate?": no. - The same key with a different body answers
409 idempotency_conflict. - A retry that arrives while the first call is still running answers
409 idempotency_in_progresswithRetry-After: 2. 5xxresponses and409 iloq_rejectedare never stored — both are transient — so a retry with the same key re-executes.- Responses larger than 64 KB, or that are not JSON, are not stored: a retry with the same key executes the request again (never a replay).
- A key that is still in progress after 60 seconds is treated as abandoned (a killed worker, a dropped connection) and the retry re-executes rather than waiting out the 24 hours.
Without the header nothing changes: POST /people still de-duplicates on phone and e-mail, and
POST /keys still sends every time.
curl -X POST "https://api.snapkey.dk/public/v1/people" \
-H "Authorization: Bearer $SNAPKEY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Mette Sørensen", "email": "mette.sorensen@example.com", "phone": "+4520123456", "company_name": "Tidevand Energi", "title": "Facility Manager", "language": "da", "location_id": 12 }'
Add Idempotency-Key: <uuid> to this request and reuse it on retry:
POST /public/v1/people
Idempotency-Key: 3f1c7a52-9b40-4f6d-8b2e-0a7c5d1e9f84