Appearance
Scopes
A scope is a resource and a verb: purchase_orders:read, sell_out:write. A key holds a list of them, chosen at mint, and reaches exactly what they name.
The reach matrix
The New key drawer shows one row per resource and three states in each: None, Read, Read and write. That is the whole grammar. A resource with no public write offers two states.
| Resource | Scopes | Opens | Sold with |
|---|---|---|---|
| Products | products:read | 19 operations | Every plan |
| Product data | pim:read | 2 operations | The Product Data add-on |
| Stock | inventory:read | 5 operations | Every plan |
| Suppliers | suppliers:readsuppliers:write | 23 operations | Every plan |
| Purchase orders | purchase_orders:readpurchase_orders:write | 26 operations | Every plan |
| Sales | sales:read | 15 operations | Every plan |
| Orders | orders:readorders:write | 18 operations | Every plan |
| Returns | returns:readreturns:write | 7 operations | Every plan |
| Stocktakes | stocktakes:readstocktakes:write | 9 operations | Every plan |
| Sandbox | sandbox:readsandbox:write | 1 operation | Every plan |
| Movements | movements:read | 1 operation | Every plan |
| Accounts | accounts:read | 7 operations | Essentials+ |
| Order book | order_book:readorder_book:write | 10 operations | Essentials+ |
| Sell-out | sell_out:readsell_out:write | 6 operations | Essentials+ |
| Planning | planning:read | 7 operations | Pro |
| Cash | cash:read | 1 operation | Pro |
| Reports | reports:read | 3 operations | Pro |
| Metrics | metrics:read | 2 operations | Every plan |
Ticking Read and write sends both strings. resource:write includes resource:read at the door, so a key minted with ["purchase_orders:write"] alone can still list the orders it posts.
Opens is how many operations of that resource this train publishes. Every row is a resource a key can be minted for today, and the rows here are exactly what the contract publishes: a scope string that is not on this page is one the mint refuses. The changelog is where a new resource arrives.
Read and write are decided by the verb, with one named exception
GET is a read. POST, PATCH, PUT and DELETE are writes. There is no list of write operations to keep in step, which has one consequence worth stating plainly: a mutating operation added to a public resource next year requires :write on the day it lands, because its method is POST. A key holding only :read is never widened by a release.
The exception is a question asked with a body. POST /metrics/query hands the compiler a query and gets rows back: it writes nothing, and it costs metrics:read. A key that had to hold metrics:write to ask a question would carry a string promising writes to a resource that has none. Where you need the cost of an operation, read its own x-tightly-scopes rather than inferring it from the method.
The read-only resources, and why they stay that way
- Products and Product data: the catalogue is written by PIM write-back and by the Shopify sync. Two writers on one catalogue is how a merchant's own edit gets overwritten by a sync nobody triggered.
- Stock: stock arrives by ETL or by a posted stocktake. A public write here would be a fourth source of truth for one number.
- Sales: what the channels reported, as they reported it. It arrives by ETL, and a public write would be a second account of one week.
- Accounts: reads today. The write path mints a trading partner for an unmatched account name, which is a decision a person makes before it is a decision an API makes.
- Planning: the plan as published and the open to buy that comes out of it. Two writers on one plan is how a planner's own edit disappears under a sync nobody triggered.
- Movements: the stock ledger. A movement is written by the document that moved the stock, so a public write here would be a way to make on hand disagree with the ledger it is the sum of.
- Cash and Reports: composed from the plan, the order book and what sold. Nothing on either is typed by hand, so there is nothing for a write to change.
- Metrics: the dictionary, and the compiler that answers questions from it. A query is a question, not a change, which is why asking one costs
metrics:read.
What no key can reach
Absence is the default: an operation is public because the registry names it, never because of anything about the operation itself. Refused by construction rather than by list:
- Identity and commerce. Sign-in, users, accounts, organisations, billing, notifications, files.
- Staff operations. Every admin prefix, and anything gated above read-and-write.
- Connection plumbing. Connections, mapping, integrations, the join desk.
- Copilot and analytics. Ask, chart generation.
- Inbound callbacks. The endpoints other systems post to.
- Composed page payloads. The dashboard, the basket, Home, Inbox. Their shape follows a page's design, so they would break every time a page moved.
- Declaring, amending, approving, closing or retiring a commitment. These are a person's act.
Asking for one of those returns 403 not_public, whatever it is and whoever asked. One answer covers a staff operation, an unmapped operation and an operation gated above what a key can be, because saying which is which maps the surface for somebody who should not have it.
A refusal names the word, not the string
json
{
"code": "scope_missing",
"message": {
"code": "scope_missing",
"desc": "This key cannot read Order book.",
"doc_url": "https://docs.tightly.io/api/guides/errors#scope_missing"
}
}The sentence uses the resource's word, because that is what somebody ticked on the reach matrix and what the page in this reference is called. The scope string is something a developer meets once, in a mint request body.
Reading the scopes a key needs
Every operation in the reference states its scopes on the operation itself, and each resource page here lists them per operation. In the spec they are the x-tightly-scopes extension, so a generated client can carry them too:
bash
curl -s https://docs.tightly.io/openapi/2026-11.json \
| jq -r '.paths | to_entries[] | .key as $p | .value | to_entries[]
| "\(.key|ascii_upcase) \($p)\t\(.value["x-tightly-scopes"]|join(" "))"'Minting a key by API
The Developer page is the door most people use. The same thing over HTTP, for an admin's own session:
bash
curl -X POST "https://api.app.tightly.io/api/v1/developer/keys" \
-H "Authorization: Bearer <a user token>" \
-H "X-Organization-ID: <your organisation>" \
-H "Content-Type: application/json" \
-d '{
"name": "Warehouse sync",
"scopes": ["inventory:read", "purchase_orders:write"],
"expires_in_days": 365
}'GET /api/v1/developer/scopes returns the resources, their words and their plan gates, which is what the reach matrix is drawn from. Minting a key is an admin act on a user session, never something one key can do for another: a key cannot mint a key.