A key holds a set of scopes. A scope is resource:action, and a request needs the scope its
endpoint declares. There are two actions, read and write, plus one exception described below.
There is no admin scope and there never will be.
This page exists so you can see the ceiling before you write code rather than discovering it from a 403 halfway through a build. It is generated from the same two records the request guard reads on every request, so it cannot fall out of step with what actually happens.
Two independent gates
A request passes only if both hold:
- The key was granted the scope. Fails with
403 INSUFFICIENT_SCOPE. Fixed by editing the key, which the business owner can do without involving anyone else. - The plan entitles the scope. Fails with
403 PLAN_UPGRADE_REQUIRED. Fixed by changing plan, which is a conversation about money.
They are checked in that order and reported separately, because they send you to different
people. GET /api/v1/me returns both sets so you never have to find out the hard way.
Scopes are not hierarchical. Holding appointments:write does not imply
appointments:read; if you need both, ask for both. Implied scopes were considered and rejected,
because a permission list you have to reason about is a permission list nobody audits.
Every scope
| Scope | Entitled by | Risk | Reaches |
|---|---|---|---|
| appointments | |||
appointments:read | Groeifeature_public_api | low |
|
appointments:writewrites | Profeature_public_api_write | high |
|
| customers | |||
customers:read | Groeifeature_public_api | medium |
|
customers:writewrites | Profeature_public_api_write | high |
|
| services | |||
services:read | Groeifeature_public_api | low |
|
| staff | |||
staff:read | Groeifeature_public_api | low |
|
| availability | |||
availability:read | Groeifeature_public_api | low |
|
| products | |||
products:read | Groeifeature_public_api | low |
|
| payments | |||
payments:read | Groeifeature_public_api | medium |
|
| webhooks | |||
webhooks:managewrites | Profeature_webhooks | high |
|
The Entitled by column is the authoritative one. It is derived from the same record the
request guard consults, so it is always right. The plan name beside it is a mirror of our pricing
table and would lag a repricing by however long it takes us to notice, which is why the machine
readable extension in the OpenAPI document is x-stippa-required-feature and not just
x-stippa-required-plan.
webhooks:manage is one scope covering both directions
Every other scope splits reading from writing. This one does not: it lists, creates, updates and deletes endpoints under a single right, because subscribe and unsubscribe are useless apart. A REST-hook consumer that can subscribe but not clean up after itself is a consumer that leaks subscriptions forever.
That makes it a mutating scope whose name does not end in :write, which is worth knowing if you
are generating anything from the scope list. Read the endpoints, not the name.
Its risk rating is high for a reason worth stating plainly: a leaked key holding it can point a
new endpoint at any address and stream every future appointment, customer and payment, including
names, emails and phone numbers, to that address silently until somebody opens the Webhooks
screen.
What no key can ever do
Each of the following is a decision rather than an omission. There is no scope for them, no plan that unlocks them, and no roadmap item to add one.
Creating, listing, rotating or revoking API keys
The escalation path that turns a leaked key into a permanent takeover. A key must never be able to widen itself, extend its own life, or mint a sibling, so key management is session-only and owner/admin-only.
Billing and subscriptions
Owner-only even in the dashboard. Money and plan changes need a human who can see the price.
Team members and invitations
Adding an org member grants access to everything. An integration that can invite users is an integration that can grant itself a session.
Modules
A key that can enable modules can enable the module that grants itself more surface, and can silently change what the business is billed for.
Business settings: slug, profile, payment provider, retention policy
These mutations are audited under the security category precisely because they change the security surface or a delivery target. Changing the slug breaks every public link; changing the payment provider redirects money.
Custom domains
Adding one calls our hosting provider. An API-reachable path to it is an API-reachable path to our hosting account.
Two-factor enrolment and recovery codes
A credential that can disable the second factor protecting the account it belongs to is not a second factor.
Refunds and marking a payment paid by hand
Both move real money and are built to run from an authenticated route with a person behind it. This is also why payments are read-only and always will be.
Customer deletion and GDPR anonymisation
Erasure is irreversible and legally consequential, so it keeps its existing audited human path.
Audit logs
Reading the audit trail through the same credential whose actions it records is a poor property. The dashboard also filters the trail by the reader role, and a key has no role to filter by.
The platform administration surface
Not tenant-facing at all. It operates across every business on the platform, so no per-business credential could be scoped to a meaningful subset of it.
Impersonation
The request guard never reads the impersonation cookie. Combining a machine credential with impersonation would produce an actor nobody can attribute.
The pattern behind most of these is the same: a credential must not be able to widen itself, outlive itself, move money, or grant a session. Everything in that class stays behind a logged-in human.
Roles do not apply
An API key has no role. Owner, admin and employee are properties of a person's session, and there is no person here. A key's rights are exactly its scopes intersected with what the plan entitles. The operations reserved for an owner in the dashboard are on the deny list under What no key can ever do: no scope reaches them, so there is no role to ask for.
For the same reason a key never carries an impersonation. The request guard does not read the impersonation cookie at all, so there is no combination of session state and credential that produces an actor nobody can attribute.