GuidesWriting Data

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:

  1. The right :write scope, and the connection's Allow changes switch on — see Scopes & Permissions.
  2. An Idempotency-Key header. There is no way to opt out.
  3. A body the endpoint recognizes. Unknown fields are a 400 validation, not a silent ignore.
API access is a Premium feature and is currently in limited beta. Write behavior described here is what the surface does today; treat the live spec as the arbiter.

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.

PropertyValue
Length1–128 characters
Retained24 hours
Bound toThe operation, the target resource, and the request body
Replay markerIdempotency-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.

A corrected retry needs a fresh key. Reusing the original key after fixing your request returns the original answer — the correction is never applied, and nothing tells you it was ignored except the 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:

  1. Read the transaction; note its version.
  2. Send the PATCH with "expected_version": <that number>.
  3. 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:

  • tags replaces every tag. [] clears them all; omitting the field leaves them alone.
  • memo takes an empty string to clear it.
  • location is group-replace: the components you send become the whole stored location, and omitted ones are cleared. Use clear_location: true to remove it entirely. location and clear_location are mutually exclusive.
  • amount changes 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 fieldThe bank's original
amountprovider_amount
merchantprovider_merchant_name
dateprovider_transaction_date
descriptionprovider_description
memoprovider_memo

These appear only once an edit has displaced them — an unedited bank row and a manual row both simply lack them.

A provider field that is present and empty is not missing data. It means the bank recorded nothing for that field, and reverting will clear the field rather than restore a value. Present-and-empty and absent are different answers, and the payload keeps them distinguishable on purpose.

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" }
    ]
  }
}
StatusMeaning
appliedThe category was assigned and the row is verified.
not_foundNo such transaction for this caller — including one on an excluded account.
invalid_categoryThe target category is not assignable by this caller — including an excluded one.
version_conflictThe expected_version you sent did not match. Nothing was written for this item.
splitThe 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.

To retry the items that failed, send a new batch with a fresh 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 getDo this
409 on PATCH with a versionRe-read the row and decide again with the version you now see.
409 on a retryYour key was reused with a different body. Mint a fresh key.
409 naming bank-synced, system, split, or a category limitStructural. Retrying will not help; change what you are asking for.
403 writes-disabledThe user's Allow changes switch is off.
403 insufficient-scopeThe connection lacks the :write scope for this operation.
403 forbiddenThe write would touch a hidden field. The message names no field, by design.
422 field-immutableYou tried to change a transaction's currency.
429Honor 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.

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].