Writing Data
How writes work on the Forbidden Finance API — the mandatory Idempotency-Key, optimistic concurrency with expected_version, bulk categorize, editing bank-synced transactions with revert, and every write endpoint with a worked example.
Overview
Eleven of the API's forty operations write. Nine of them touch transactions and categories; the other two are on goals and one is on budgets.
Every write, without exception, needs three things:
- The right
:writescope, and the connection's Allow changes switch on — see Scopes & Permissions. - An
Idempotency-Keyheader. There is no way to opt out. - A body the endpoint recognizes. Unknown fields are a
400 validation, not a silent ignore.
Every write returns 200
Including the creates. This is a deliberate departure from REST convention, and the reason is idempotency: a replayed response has to be byte-identical to the original, and a replay cannot honestly claim to have created anything. Two different status codes for one stored response would be worse than the purism is worth.
So: check the envelope, not the status line, to learn what happened.
Idempotency
Every write carries an Idempotency-Key header of your choosing.
| Property | Value |
|---|---|
| Length | 1–128 characters |
| Retained | 24 hours |
| Bound to | The operation, the target resource, and the request body |
| Replay marker | Idempotency-Replayed: true on the response |
A UUID per logical write is the easy correct choice.
Same key, same request replays the stored response verbatim and changes nothing. The response carries Idempotency-Replayed: true so you can tell a replay from a fresh apply.
Same key, anything different — a changed body, a different endpoint, a different {id} — is a 409 conflict. A duplicate that is still in flight is also a 409, with Retry-After.
The key is bound to the target and not only to the payload, which matters for deletes: they carry no body at all, so without target binding one key reused across two deletes would replay the first one's response and quietly skip the second.
Idempotency-Replayed header. This is the single most common way to lose a write. Mint a new key whenever the request body changes.Optimistic concurrency
Transactions carry a version integer. PATCH /v1/transactions/{id} requires it back as expected_version:
- Read the transaction; note its
version. - Send the
PATCHwith"expected_version": <that number>. - On
409 conflict, re-read the row and decide again with the value you now see.
version is always present, including on a row at version 0. Do not cache one across a long-running job — read, decide, write, in that order, close together.
Two endpoints deliberately skip this. POST /v1/transactions/{id}/annotate reads the row and uses its current version, so a categorizing caller need not carry concurrency tokens at all — a row that changes underneath still loses the race and returns 409. And on bulk categorize, expected_version is optional per item: omit it to apply regardless, or send it to have that one item come back version_conflict rather than overwrite a change you did not see.
What a write through this API will not do
Two guarantees are applied to every transaction edit, and neither is optional:
- It never teaches the categorizer. No per-merchant rule is created or updated, no vote reaches the global category model, and no categorization event is published. One integration's opinion about one merchant does not become the default for everything else you own — or for anyone else.
- It never flattens a split. A category change on a split parent is refused rather than applied over the slices you set by hand. On bulk categorize that row simply comes back with status
split.
The transaction endpoints
Create a manual transaction
POST /v1/transactions — scope transactions:write.
curl -X POST https://api.403fin.io/v1/transactions \
-H "Authorization: Bearer ff_ak_your_key_here" \
-H "Idempotency-Key: 6f1c9a2e-0f2c-4f2b-9a1e-3c7d5b8e4a10" \
-H "Content-Type: application/json" \
-d '{
"account_id": "6a3f1b2c-8d4e-4f10-9c2b-1e5a7d3f9b04",
"amount": "-42.75",
"date": "2026-08-20",
"merchant": "Blue Bottle",
"category_id": "b21e7c94-3a6d-4f88-90f1-2c4e6a8b0d33",
"memo": "Team offsite"
}'
amount is a decimal string, negative for spending. currency_code defaults to the account's currency. The account must be one this connection can see — an excluded account and a nonexistent one both return the same 404, so a hidden account cannot be found by probing which ids accept a write.
Some fields the first-party app has are not on this body, and sending them is a 400 validation: apply_to_merchant and create_merchant_rule (a programmatic write never teaches the categorizer), splits and receipt ids (v1 exposes neither resource), use_subcategories, and category_from_ai_suggestion — that last one is a record of a human accepting a suggestion, which is not something an integration can assert on someone's behalf.
Update a transaction
PATCH /v1/transactions/{id} — scope transactions:write, expected_version required.
curl -X PATCH https://api.403fin.io/v1/transactions/9c2b1e5a-7d3f-4b04-8a6e-2f1c9d4b7e08 \
-H "Authorization: Bearer ff_ak_your_key_here" \
-H "Idempotency-Key: 2b7e4f10-9c2b-4a1e-8d4e-6f1c9a2e0f2c" \
-H "Content-Type: application/json" \
-d '{
"expected_version": 3,
"merchant": "Blue Bottle Coffee",
"tags": ["work", "coffee"]
}'
Field semantics worth knowing before you build a form on top of this:
tagsreplaces every tag.[]clears them all; omitting the field leaves them alone.memotakes an empty string to clear it.locationis group-replace: the components you send become the whole stored location, and omitted ones are cleared. Useclear_location: trueto remove it entirely.locationandclear_locationare mutually exclusive.amountchanges the number, never the currency.
Currency is immutable
Changing a transaction's currency is a 422 field-immutable:
A transaction's currency is set when it is created and cannot be changed. Delete the transaction and create it again in the currency you want.
Editing a bank-synced transaction
Bank-synced rows are fully editable here, and nothing is destroyed when you edit one. At the row's first user edit, the bank-recorded value of each field is captured. From then on the original is visible alongside the current value:
| Current field | The bank's original |
|---|---|
amount | provider_amount |
merchant | provider_merchant_name |
date | provider_transaction_date |
description | provider_description |
memo | provider_memo |
These appear only once an edit has displaced them — an unedited bank row and a manual row both simply lack them.
To hand a field back to the bank, name it in revert_to_provider. That restores what the bank recorded and returns ownership to the provider, so later syncs update the field again:
curl -X PATCH https://api.403fin.io/v1/transactions/9c2b1e5a-7d3f-4b04-8a6e-2f1c9d4b7e08 \
-H "Authorization: Bearer ff_ak_your_key_here" \
-H "Idempotency-Key: 41d9b3e7-5c8a-4e02-b6f1-7a3d2c9e5b16" \
-H "Content-Type: application/json" \
-d '{
"expected_version": 4,
"revert_to_provider": ["merchant", "amount"]
}'
Accepted names: amount, merchant, description, date, memo. Naming a field the same request also sets is a 400. Naming a field with no captured bank value — an unedited row, or a manual one that never had a bank value — is a 409.
The provider fields follow the same privacy rules as the fields they shadow: provider_amount is hidden with amount, provider_merchant_name with merchant, and so on. provider_transaction_date is unredacted, like date itself.
Delete a transaction
DELETE /v1/transactions/{id} — scope transactions:write.
curl -X DELETE https://api.403fin.io/v1/transactions/9c2b1e5a-7d3f-4b04-8a6e-2f1c9d4b7e08 \
-H "Authorization: Bearer ff_ak_your_key_here" \
-H "Idempotency-Key: 8e4a10b2-1e5a-4d3f-9b04-6a3f1b2c8d4e"
Manual and imported transactions delete. Bank-synced transactions do not, and return 409 conflict:
Bank-synced transactions cannot be deleted. The bank is the source of truth for them.
The next sync would recreate the row, so the deletion would not be a deletion. Hide or re-categorize instead.
Annotate a transaction
POST /v1/transactions/{id}/annotate — scope transactions.annotate:write (or transactions:write, which subsumes it). No expected_version.
curl -X POST https://api.403fin.io/v1/transactions/9c2b1e5a-7d3f-4b04-8a6e-2f1c9d4b7e08/annotate \
-H "Authorization: Bearer ff_ak_your_key_here" \
-H "Idempotency-Key: c07f2a91-6b3e-4d85-a1f0-9e2c4b7d5308" \
-H "Content-Type: application/json" \
-d '{
"category_id": "b21e7c94-3a6d-4f88-90f1-2c4e6a8b0d33",
"tags": ["subscription"],
"memo": "Annual plan"
}'
Category, tags, memo, and location — nothing else. At least one field is required. This is the endpoint an unattended categorizer should be built on.
Categorize in bulk
POST /v1/transactions/categorize — scope transactions.annotate:write, 1 to 100 items.
curl -X POST https://api.403fin.io/v1/transactions/categorize \
-H "Authorization: Bearer ff_ak_your_key_here" \
-H "Idempotency-Key: 5d81e6c3-4a09-4b7f-8e21-0c6a3f9d2b74" \
-H "Content-Type: application/json" \
-d '{
"items": [
{ "transaction_id": "9c2b1e5a-7d3f-4b04-8a6e-2f1c9d4b7e08",
"category_id": "b21e7c94-3a6d-4f88-90f1-2c4e6a8b0d33" },
{ "transaction_id": "1e5a7d3f-9b04-4a6e-8c2b-3f1b2c8d4e10",
"category_id": "b21e7c94-3a6d-4f88-90f1-2c4e6a8b0d33",
"expected_version": 2 }
]
}'
The outcome is partial by design. A mixed result is still 200, and every submitted item explains itself:
{
"data": {
"applied_count": 1,
"results": [
{ "transaction_id": "9c2b1e5a-…", "status": "applied" },
{ "transaction_id": "1e5a7d3f-…", "status": "version_conflict" }
]
}
}
| Status | Meaning |
|---|---|
applied | The category was assigned and the row is verified. |
not_found | No such transaction for this caller — including one on an excluded account. |
invalid_category | The target category is not assignable by this caller — including an excluded one. |
version_conflict | The expected_version you sent did not match. Nothing was written for this item. |
split | The row is a split parent whose slices carry the attribution. Refused rather than flattened. |
not_found and invalid_category are deliberately coarse: a row hidden by exclusions is indistinguishable from one that never existed.
Idempotency-Key. Replaying the original key returns the original mixed result and writes nothing — and reusing it with a corrected body is a 409. Exclusions are applied server-side from the connection's own settings and cannot be supplied in the request.A duplicate transaction_id in one batch is a 400 for the whole batch: two assignments for one row would produce two results with no way to tell which describes the stored state.
The category endpoints
Create a category
POST /v1/categories — scope categories:write.
curl -X POST https://api.403fin.io/v1/categories \
-H "Authorization: Bearer ff_ak_your_key_here" \
-H "Idempotency-Key: a94c2f70-3b18-4e6d-95a2-7f0b1c8e6d43" \
-H "Content-Type: application/json" \
-d '{
"name": "Coffee",
"parent_id": "4f88b21e-7c94-4a6d-90f1-2c4e6a8b0d33",
"icon": "emoji:☕",
"color": "#FF5733"
}'
name is capped at 60 characters. Categories nest exactly one level, so parent_id must name a visible top-level category. icon takes a Material icon name or "emoji:<grapheme>".
Hitting the per-account category limit is a 409, not a rate limit — waiting does not help:
This account has reached its limit on custom categories. Delete one before creating another.
Update a category
PATCH /v1/categories/{id} — scope categories:write. At least one field required.
curl -X PATCH https://api.403fin.io/v1/categories/b21e7c94-3a6d-4f88-90f1-2c4e6a8b0d33 \
-H "Authorization: Bearer ff_ak_your_key_here" \
-H "Idempotency-Key: 3e60d1a8-95c7-4f2b-8a04-6d1e9b3c7f52" \
-H "Content-Type: application/json" \
-d '{ "name": "Coffee & Tea", "clear_parent": true }'
An empty string means "leave unchanged" here, so a name or icon cannot be blanked through this endpoint. Removing a parent has its own flag, clear_parent, which moves the category to the top level.
Delete a category
DELETE /v1/categories/{id} — scope categories:write. The body is optional.
curl -X DELETE https://api.403fin.io/v1/categories/b21e7c94-3a6d-4f88-90f1-2c4e6a8b0d33 \
-H "Authorization: Bearer ff_ak_your_key_here" \
-H "Idempotency-Key: 7b2f4c8e-1d60-4a93-b5e7-0c3a9f2d6b81" \
-H "Content-Type: application/json" \
-d '{ "merge_into_category_id": "4f88b21e-7c94-4a6d-90f1-2c4e6a8b0d33" }'
With no body, the category's transactions become uncategorized. With merge_into_category_id they are reassigned to that category, which must itself be visible to this connection. The result reports transactions_merged.
A category with subcategories is a 409 — move or delete them first.
System categories are not yours to change
PATCH and DELETE on a seeded system category (is_system: true) both return 409 conflict:
System categories cannot be changed or deleted through the API.
Every other feature in the app keys on that tree. Check is_system on a category before offering it as editable in your UI.
The goal and budget endpoints
Three more writes round out the eleven.
PATCH /v1/goals/{id} (scope goals:write) changes exactly four fields — name, description, target_amount, target_date. Anything else in the body, including status, is_shared, or sort_order, is a 400 validation. Send an empty string to clear the description or the target date.
POST /v1/goals/{id}/contributions (scope goals:write) records a manual contribution. currency_code defaults to the goal's currency. A goal whose tracking_mode is balance_mirror refuses this with 409 — it republishes a linked account's live balance and records contributions automatically:
This goal mirrors a linked account's balance and records contributions automatically. Adjust the target instead of contributing.
Read tracking_mode before you call and you will never see that error.
POST /v1/budgets/method-switch (scope budgets:write) is the one to be careful with. It does not edit the current budget — it archives it and creates a new one for the chosen method. confirm must be literal true, or you get a 400. A shared budget cannot be switched here at all: it returns 409 partner-approval-required, because that approval flow lives only in the app.
Error handling checklist
The full taxonomy is on Getting Started. For writes specifically:
| You get | Do this |
|---|---|
409 on PATCH with a version | Re-read the row and decide again with the version you now see. |
409 on a retry | Your key was reused with a different body. Mint a fresh key. |
409 naming bank-synced, system, split, or a category limit | Structural. Retrying will not help; change what you are asking for. |
403 writes-disabled | The user's Allow changes switch is off. |
403 insufficient-scope | The connection lacks the :write scope for this operation. |
403 forbidden | The write would touch a hidden field. The message names no field, by design. |
422 field-immutable | You tried to change a transaction's currency. |
429 | Honor Retry-After. Writes are capped at 30 per minute per connection. |
Frequently Asked Questions
Can I skip the Idempotency-Key on a read-only-ish write, like annotate?
No. Every write requires one, with no exception and no way to opt out. A missing or over-long key is a 400 validation.
I fixed my request and retried with the same key. Nothing changed. Why?
A key is bound to the body, so the same key with a different body is a 409 — and the same key with the same body replays the stored response. Either way the correction never applies. A corrected retry always needs a fresh key.
How do I tell a replay from a real apply?
The response carries Idempotency-Replayed: true when it is a replay. The body is byte-identical to the original either way, so the header is the only signal.
Will editing a bank-synced transaction be overwritten by the next sync?
No. Editing a field hands ownership of it to you, and later syncs leave it alone. revert_to_provider is how you hand a field back so the bank updates it again.
Does categorizing through the API teach the app to categorize that merchant?
No. Every write here suppresses rule learning — no merchant rule is created or updated, and no vote reaches the global category model. Categorization rules are something the person sets in the app.
Can a write delete an account, move money, or reach my bank login?
No. Those operations do not exist on this API. The write surface is transactions, categories, budgets, and goals.
Related Articles
Scopes & Permissions
The 16 scopes and the two-switch rule.Getting Started with the API
Auth, envelopes, errors, and limits.Data Privacy & Redaction
Why a write can be refused without naming a field.MCP Server Reference
The same writes, as MCP tools.Need more help? Contact us at [email protected].