Scopes & Permissions
The 16 Forbidden Finance API scopes, how write scopes imply read scopes, the two-switch rule that gates every write, and how redaction and exclusion narrow a connection further.
Overview
A connection's permissions are three separate things, and all three have to line up before a request succeeds:
- Scopes — which areas of data this connection may touch, and whether it may change them.
- The Allow changes switch — a per-connection opt-in that arms writes at all.
- Privacy settings — which fields are stripped, and which accounts or categories are hidden.
Scopes and the switch decide whether an operation runs. Privacy settings decide what it can see and touch. This page covers all three.
The 16 scopes
These are the exact scope strings the authorization server advertises in scopes_supported, alongside the label the user sees on the consent screen. Read scopes are pre-checked at consent; write scopes are off by default.
| Scope | Consent label | Grants |
|---|---|---|
accounts:read | Accounts | Account list, one account, balances |
sync:read | Connection sync status | Connection list, aggregate sync health |
transactions:read | Transactions | Transaction list, one transaction, search |
transactions.annotate:write | Categorize & annotate transactions | Annotate one transaction, bulk categorize |
transactions:write | Edit transactions (add, change, delete) | Create, full edit, delete — plus everything annotate covers |
categories:read | Categories | Category list |
categories:write | Manage custom categories | Create, update, delete custom categories |
budgets:read | Budgets | Budget list, one budget, budget progress |
budgets:write | Change budgets | Switch the active budgeting method |
goals:read | Goals | Goal list, one goal, progress, history, contributions |
goals:write | Change goals | Update a goal, record a contribution |
net_worth:read | Net worth | Net-worth summary and history |
investments:read | Investments | Holdings |
liabilities:read | Liabilities & debt | Liabilities, debt summary, debt payoff plan |
bills:read | Bills & recurring | Detected recurring rules, upcoming bills |
insights:read | Spending insights | Spending by category, income vs expenses, month summary, cash-flow forecast |
Two mappings surprise people:
- Connections belong to
sync:read, notaccounts:read. Listing bank connections and reading aggregate sync health both needsync:read. If you are resolving a transaction all the way back to its bank, you needtransactions:read,accounts:read, andsync:read. - The debt payoff planner needs
liabilities:read, and nothing more. It computes a plan and writes nothing, so it is a read despite being aPOST.
Write scopes imply their read scopes
Holding a write scope automatically carries the read scope beneath it. You never need to ask for both.
| Granting this | Also gives you |
|---|---|
transactions.annotate:write | transactions:read |
transactions:write | transactions.annotate:write, and therefore transactions:read |
categories:write | categories:read |
budgets:write | budgets:read |
goals:write | goals:read |
Implication is transitive and is resolved server-side on every request, so a connection granted transactions:write can annotate without transactions.annotate:write ever appearing in its stored scope list.
Two sizes of transaction write
Transaction writes are deliberately split, because "help me tidy up my categories" and "you may change what I spent" are not the same request.
transactions.annotate:write | transactions:write | |
|---|---|---|
| Set category, tags, memo, location | Yes | Yes |
| Bulk categorize | Yes | Yes |
| Change amount, merchant, description, date | No | Yes |
| Create a transaction | No | Yes |
| Delete a transaction | No | Yes |
| Endpoints reached | POST /v1/transactions/{id}/annotate, POST /v1/transactions/categorize | those two, plus POST /v1/transactions, PATCH /v1/transactions/{id}, DELETE /v1/transactions/{id} |
A connection holding only the narrow scope gets insufficient-scope on create, PATCH, and delete. That refusal is the point: it is a promise you can make to yourself in writing.
transactions.annotate:write and stop there. It covers the whole of the categorize-and-tidy use case, and it structurally cannot change an amount or remove a row. Reach for transactions:write only when you genuinely need to create or delete transactions.categories:write is separate again, and narrow by construction: it creates, renames, re-parents, restyles, and deletes custom categories. The seeded system categories are refused with 409 conflict — the rest of the app keys on that tree, so it is not a programmatic caller's to rewrite.
The two-switch rule
A write scope on its own does nothing. Every write requires both:
- the relevant
:writescope on the connection, and - the connection's Allow changes switch turned on.
They are set at different moments and answer different questions. Scopes say what kind of change is permitted; the switch says whether this connection may change anything at all. It is off by default on a new connection, and a user can flip it off later without touching the scope list — which instantly makes the connection read-only while leaving its configuration intact for when they turn it back on.
| Situation | Result |
|---|---|
| No write scope, switch off | insufficient-scope (403) |
| No write scope, switch on | insufficient-scope (403) |
| Write scope granted, switch off | writes-disabled (403) |
| Write scope granted, switch on | The write runs |
Handle writes-disabled distinctly from insufficient-scope in your client. They mean different things to the person who has to fix them: one is "ask for more permission", the other is "flip the switch you already have".
tools/list only when the connection holds its scope and has changes enabled. An assistant does not see a tool it would be refused. See MCP Server Reference.Privacy narrows what a scope reaches
Scopes are coarse — a whole area of data. Privacy settings cut into that area, and they are applied on Forbidden Finance's servers, after the scope check.
Redaction removes individual fields. There are 18 of them, and the list is fixed: a connection cannot ask for a field the registry does not name.
| Area | Fields |
|---|---|
| Transactions | Merchant name, description, amount, tags, check number, external reference, memo, purchase location |
| Accounts | Account name, account number mask, balance |
| Bank connections | Institution, connection error message |
| Investments | Holding name & ticker, holding value |
| Liabilities | Liability name, liability balance |
| Goals | Goal name & description |
A redacted field is omitted from the payload — not blanked, not masked. Its id appears in the envelope's redacted_fields so you know something was removed.
Exclusion hides whole accounts or categories. An excluded row is absent from every list and from search, and asking for it by id returns the same not-found a nonexistent id returns.
What that means for writes
A write cannot change what the connection is not allowed to read. Attempt one and the whole request is refused with forbidden (403), carrying a single fixed message:
This connection's privacy settings hide a field this request would change.
It names no field. Which fields are hidden is itself part of the configuration, so a per-field answer would let a caller enumerate the privacy settings by probing writes one field at a time. The refusal is the same string every time, for every caller, in every case.
The practical consequence: read the connection's own capabilities from what it actually returns. If transaction.merchant shows up in redacted_fields, do not try to set a merchant.
Checking what you were granted
Nothing about a connection's scopes is secret from the connection itself. The most reliable check is the cheapest one: make the read you plan to build on and see whether it succeeds. A 403 with insufficient-scope means the scope is absent; a 200 whose envelope carries redacted_fields or filtered: true means the scope is present but narrowed.
For OAuth clients, the authorization server publishes the full scope vocabulary at https://api.403fin.io/.well-known/oauth-authorization-server under scopes_supported. Request the narrowest set that does the job — the consent screen shows the user exactly what you asked for, and a shorter list is approved more often than a long one.
Frequently Asked Questions
Do I need to ask for a read scope alongside its write scope?
No. A write scope carries its read scope automatically, and the implication is transitive — transactions:write reaches annotate and read without either being listed.
The user turned on Allow changes but my write still fails with insufficient-scope. Why?
The switch arms writes; it does not grant them. The connection also needs the specific :write scope for that operation. Have the user edit the connection's scopes under Settings > AI & API Connections.
Can I request a scope the user did not grant, and get it added?
No. A connection receives exactly what the user approved at consent, and it cannot widen its own access. Changing scopes is something the user does in the app.
Why did my write get a 403 with no field named?
A field the request would have changed is hidden by this connection's privacy settings. The message deliberately does not say which one — naming it would let a caller map the hidden set by probing. Check the envelope's redacted_fields on a read of the same resource.
Is there a scope for moving money, or for bank credentials?
No. There is no such operation on this API at all. The write surface is transactions, categories, budgets, and goals — nothing reaches payments, transfers between institutions, or the credentials behind a bank connection.
Related Articles
Getting Started with the API
Auth, envelopes, errors, pagination, and limits.Writing Data
Idempotency, versioning, and every write endpoint.Data Privacy & Redaction
The full privacy-gateway model.AI & API Connections
Create and manage connections in the app.Need more help? Contact us at [email protected].