Quickstart
Before you start you need an Era account with at least one connected institution — without a connection these endpoints have nothing to return.
- 1
Sign in to Era and connect an institution, if you haven't already.
- 2
Open your API keys in the dashboard and create a key. Every scope is ticked to start with, so untick the ones you don't need — for these endpoints that leaves banking:read. You also pick an expiry; there's no never-expires option.
- 3
Copy the key. It's shown once, and we can't show it again. Copy it and store it somewhere safe, such as a secrets manager. If you lose a key, you can't view it again. Create a new key instead.
- 4
Send it in a header with your request.
cURL
curl "https://forge.era.app/api/banking/transactions?page=1&pageSize=20" \
-H "X-API-Key: fmk_your_key_here"
Response · 200
{
"transactions": [ … ],
"pagination": {
"currentPage": 1,
"pageSize": 20,
"totalItems": 412,
"totalPages": 21
},
"historyWindowApplied": true,
"historyWindowFloorDate": "2026-06-28",
"historyWindowHiddenCount": 137,
"historyWindowEarliestDate": "2024-03-02",
"historyWindowDegraded": false
}
Available APIs
The Era API includes the following APIs:
- GET/banking/accounts
Every account you can see, across every connected institution.
- GET/banking/accounts/{accountId}/balance
One account's balance, including credit fields on a liability.
- GET/banking/accounts/summary
Totals across every account you can see.
- GET/banking/transactions
Your transactions, a page at a time.
- PUT/banking/transactions/{id}
Change one transaction
- PUT/banking/transactions/bulk
Change up to 100 at once
- GET/banking/categories
The whole category taxonomy, packs and sub-categories nested.
- POST/banking/categories
Add a category
- GET/banking/tags
Every tag on your account, in one response.
- POST/banking/tags
Create a tag
Authentication
Send your key either of two ways:
| Method | Credential |
|---|---|
| Header | X-API-Key: fmk_your_key_here |
| Bearer token | Authorization: Bearer fmk_your_key_here |
Every request is encrypted with TLS.
Keys expire, and you choose how soon when you make one. The longest available is 90 days on the free plan and 365 on a paid one — there is no never-expires option, so anything you build against this needs a plan for rotating the key before it lapses.
Response headers
Every response includes:
| Header | Description |
|---|---|
| fly-request-id | A unique identifier for the request. Include it when you contact support about a specific request — see Request ID |
Errors
The API returns these error status codes:
- 400
Malformed input: a bad parameter, an empty or over-100 bulk update, or a write that sets and clears the same field in one call.
- 401
No key, or one that doesn't parse. Send it as X-API-Key or as a bearer token.
- 402
A plan quota is in the way — today, that's category creation only.
- 403
The key doesn't carry the scope this call needs — or, on either transaction write, the id belongs to someone else or doesn't exist at all. The API doesn't tell those two apart.
- 409
Something else changed the row while you were writing to it. Read it again and send your write again.
Error shapes
Every error comes back in the same shape — statusCode, message, and an errors object naming what was wrong. A missing or invalid key can come back with no body at all.
Example
{
"statusCode": 403,
"message": "One or more errors occurred!",
"errors": {
"generalErrors": ["Transaction does not belong to the authenticated user"]
}
}
Request ID
Every response carries a fly-request-id header. Include it when you contact support about a specific request.
cURL
# Print the response headers, including fly-request-id; discard the body
curl -sS -D - -o /dev/null "https://forge.era.app/api/banking/transactions?page=1&pageSize=20" \
-H "X-API-Key: fmk_your_key_here"
cURL (write call)
# A write call: same header, plus a JSON body
curl -sS -D - -X PUT "https://forge.era.app/api/banking/transactions/utgr_your_transaction_id" \
-H "X-API-Key: fmk_your_key_here" \
-H "Content-Type: application/json" \
-d '{"categoryKey": "fcat_dining", "merchantName": "Corner Cafe"}'
Common errors
Setting a field and clearing it in the same write — 400.
More than 100 ids in a bulk update — 400, and nothing is changed. Fewer than one is the same.
A transaction that isn't yours, or isn't there at all — 403, never 404. So the response never tells you whether an id exists, only that it isn't yours to see.
Someone or something else changed the row first — 409.
Limits
Two of the ten documented endpoints cap how much you can ask for in one call. The other eight don't.
That doesn't mean unlimited: keys still expire, writes still have field-length limits, and your plan can still hide older history. None of this is a rate limit — see below.
Accounts, balance, summary, categories, tags, and the single-transaction write have no per-call volume cap. You get the whole set back, or the one row you named.
pageSize is clamped to 100, not refused. Ask for more and you get 100 rows back with a 200 — read pagination.pageSize in the response rather than trusting what you sent.
The bulk transaction write is capped at 100 ids, and unlike pageSize it's refused rather than clamped: send 101 and you get a 400 and nothing changes.
Keys expire on a schedule you choose at creation — up to 90 days on the free plan, 365 on a paid one. There's no never-expires option.
Your plan can apply a history-window floor that hides older transactions. The transactions response carries the historyWindow fields that tell you whether one applied and where it fell.
Plans also state an API-request allowance — 500 on the free plan, more on every paid one.
What isn't here: there's no per-request throttling on REST today, no 429, and no rate-limit headers — so a leaked key isn't slowed down by anything on this end. If you're ever unsure about a key, revoke it: that cuts it off immediately (see Security and key handling above). And, because this API is in beta, don't assume that stays true.
Conventions
Response fields are camelCase. Query parameters are case-insensitive, so camelCase works there too — the published spec spells them PascalCase, which is why you'll see both forms around.
A field naming a calendar day is YYYY-MM-DD. A field naming an instant is ISO 8601 with an offset.
Paging sizes are clamped, not refused. Ask for a pageSize of 500 and you get 100 rows and a 200, not an error — so read pagination.pageSize back rather than trusting what you sent.
A response can carry fields this page doesn't list. Ignore the ones you don't recognize rather than failing on them — that's what keeps your client working as the API grows.
REST is plain HTTP; there's no SDK to install. Any language with an HTTP client works.
Security and key handling
Approving an agent creates a key
When you approve an agent over OAuth, Era makes an API key for it. It lands in the same dashboard list as the ones you make yourself, under a name Era generates from the client's own name.
How it's named
Auto -- ClaudeIt carries exactly the scopes you approved on that screen, and nothing else. Revoke it from the dashboard and the agent stops reaching your account until you approve it again.
Plain REST calls aren't logged
Making and revoking a key both show up in your activity log, and so does every tool call an agent makes over MCP. Per-request REST calls don't — reads or writes — there's no per-call log on that path today.
If you're ever unsure about a key, revoke it. Revocation is immediate, cuts the key off on both REST and MCP, and a replacement takes a minute.
| Fact | What it means |
|---|---|
| Scopes are coarse | banking:read reads more than the six on this page — the same scope covers the rest of your account's reads too: balances, holdings, connections, spending. One scope, no narrower option. Write scopes are on the menu too, same as reads — so treat any key like a password. It acts as your account, not a slice of it. banking:write can change categories, tags, and transaction metadata, manage manual accounts and balances, and connect or disconnect institutions — no scope on this page can move money between your bank accounts. |
| Scopes don't update | A key's scopes are fixed when you make it and never change afterwards. That matters for anything a scope covers that isn't switched on yet: grant social:write today and the key still has it when shared views arrive. Grant what you're using now, not what you might use later. |
| No approval needed | You're already signed in to your own account, so making a key needs nobody else's sign-off — there's no review and no waiting list, and nobody at Era approves the request. It's written to your activity log the moment you make it, so an unfamiliar key is easy to spot. |
| Bank login stays out of reach | A key can't reach your bank login, because Era never has it. You enter it in the connection flow run by the data provider, not on an Era screen — what Era keeps afterwards is a per-connection access token, encrypted at rest with AES-256, that you can throw away by disconnecting the institution. |
| Keys are hashed, not stored | Your key is 256 bits of random data, hashed with SHA-256 before it's stored. We keep the hash, not the key. Lose it, revoke it, and make a new one. |
Core resources
Accounts
Scope required
banking:readEvery account you can see, across every connected institution, with the count of the ones left out beside them. Each account carries its accountGroupKey — the value the balance endpoint takes in its path — and the connectionId it belongs to, so this is the call to make first. Takes connectionId to narrow to a single connection, and includeExcluded to bring in accounts you've hidden.
connectionIdoptional | Narrow the list to one connection's accounts. |
|---|---|
includeExcludedoptional | Include tier-excluded and hidden accounts too, with their balances obfuscated. Defaults to false. |
Response · 200
{
"accounts": [
{
"accountGroupKey": "uagr_7f3c9a21",
"connectionId": "ucon_4b19e02c",
"name": "Everyday Checking",
"currentBalance": 4820.16,
"supportsTransactions": true,
…
}
],
"excludedAccountCount": 1
}
On an account you've hidden or your plan excludes, the balance fields come back null rather than zero — null means withheld, not empty. supportsTransactions is null in the same spirit: it means Era can't say, and never that the answer is no.
Account balance
Scope required
banking:readOne account's balance, with the credit fields filled in when the account is a liability. The path takes that account's accountGroupKey — the same value /banking/accounts returns for it. A key in another shape is rejected before the lookup runs.
Response · 200
{
"accountGroupKey": "uagr_7f3c9a21",
"currentBalance": 4820.16,
"availableBalance": 4712.03,
"creditLimit": null,
"currencyCode": "USD",
"availableCredit": null,
"asOf": "2026-08-11T09:32:00Z",
"visibility": null
}
A hidden account, or one whose connection was severed, still answers 200 — with the balance fields null. Only an account that genuinely isn't there gives you a 404. Watch the visibility field here: it's null when the account is visible, and a string such as tier_excluded when it isn't.
Account summary
Scope required
banking:readTotals across the accounts you can see: totalAssets, totalLiabilities, and netWorthHint, which is the first minus the second. Takes no parameters.
Response · 200
{
"userId": "7d1c0b93a8e24f60",
"accounts": [ … ],
"totalVisibleCount": 6,
"totalHiddenCount": 2,
"totalAssets": 48210.75,
"totalLiabilities": 9327.40,
"netWorthHint": 38883.35,
"computedAt": "2026-08-11T09:32:00Z"
}
netWorthHint counts only the accounts in this response, so totalHiddenCount tells you what it's missing. Treat it as a starting figure rather than an authoritative net worth.
Transactions
Scope required
banking:readYour transactions, a page at a time, wrapped in an envelope with the paging counts beside them. Takes page and pageSize (100 is the ceiling), plus optional filters for account, date range, applied rules, and assigned tags.
accountIdoptional | Narrow to one account's transactions, by its accountGroupKey. |
|---|---|
fromDateoptional | Only transactions on or after this date. |
toDateoptional | Only transactions on or before this date. |
pageoptional | Page number, 1-indexed. Defaults to 1. |
pageSizeoptional | Rows per page. Defaults to 50, clamped to 100. |
sortByoptional | Field to sort by: transactionDate, amount, description, category, or merchantName. |
sortDirectionoptional | asc or desc. Defaults to descending. |
categoryKeyoptional | Only transactions in one category, by its fcat_ key. |
searchoptional | Full-text search across merchant, description, category, account name, and amount. |
ruleIdsoptional | Only transactions an automation rule touched, by the rule's key. |
tagKeysoptional | Only transactions carrying one of these tags. |
reviewStatusoptional | needs_review, reviewed, or flagged. |
includeChildrenoptional | With categoryKey set, also include its subcategories. Defaults to false. |
Response · 200
{
"transactions": [ … ],
"pagination": {
"currentPage": 1,
"pageSize": 20,
"totalItems": 412,
"totalPages": 21
},
"historyWindowApplied": true,
"historyWindowFloorDate": "2026-06-28",
"historyWindowHiddenCount": 137,
"historyWindowEarliestDate": "2024-03-02",
"historyWindowDegraded": false
}
Your plan may apply a history-window floor, which hides transactions older than it. That's why the response carries the historyWindow fields: historyWindowApplied tells you a floor actually hid something, historyWindowFloorDate is where it fell, historyWindowHiddenCount is how many rows are behind it, and historyWindowEarliestDate is how far your history really goes. Without them a short result set is indistinguishable from an account with no older transactions. Two of them change what you write: historyWindowHiddenCount can be null even when a floor applied, so read null as unknown rather than zero; and when historyWindowDegraded is true, Era couldn't confirm your plan on that read, so the floor date is a guess rather than a fact. On a confirmed paid read no floor applies and historyWindowApplied comes back false.
Plans also state an API-request allowance — 500 on the free plan, more on every paid one. The current figures are listed with the rest of your plan's limits.
Change one transaction
Four things on a transaction are yours to override: its category, the merchant name, a note of your own, and its review status. Send only the ones you're changing — anything you leave out stays as it is. The id in the path is the transaction's utgr_ key. Mutating, so it needs banking:write rather than banking:read.
Scope required
banking:writecategoryKeyoptional | The fcat_ key of the category to assign. Leave it out and the transaction keeps the category it has. |
|---|---|
merchantNameoptional | A merchant name of your own, up to 1000 characters. Leave it out and the current name stays. |
descriptionoptional | A note of your own on this transaction, up to 5000 characters. Leave it out and the current note stays. |
reviewStatusoptional | Mark it needs_review, reviewed, or flagged. |
clearCategoryoptional | Drops your category override, so Era's own categorization takes over again. Defaults to false. |
clearMerchantNameoptional | Drops your merchant-name override, so the name your bank sent comes back. Defaults to false. |
clearDescriptionoptional | Drops your description override, so the description your bank sent comes back. Defaults to false. |
clearReviewStatusoptional | Drops your review-status override. Defaults to false. |
Response · 200
{
"transaction": { … }
}
You get the whole updated transaction back, in the same shape the list above returns — not reprinted here, because it's a large object that's still moving. Setting a field and clearing it in the same call comes back 400. A transaction that isn't yours, or isn't there at all, comes back 403 — the API doesn't tell those two apart. And if something else changed the same row while you were writing, you get 409: read it again and send it again.
Change up to 100 at once
The same four overrides, applied to a list of transactions in one call. Every id in the list gets the same changes — there is no per-transaction variation. Mutating, so it needs banking:write rather than banking:read.
Scope required
banking:writetransactionIds | The utgr_ keys of the transactions to change. At least one, and no more than 100. Over 100 is refused rather than trimmed — unlike pageSize above, you get a 400 and nothing changes at all. |
|---|---|
categoryKeyoptional | The fcat_ key of the category to assign. Leave it out and the transaction keeps the category it has. |
merchantNameoptional | A merchant name of your own, up to 1000 characters. Leave it out and the current name stays. |
descriptionoptional | A note of your own on this transaction, up to 5000 characters. Leave it out and the current note stays. |
reviewStatusoptional | Mark it needs_review, reviewed, or flagged. |
clearCategoryoptional | Drops your category override, so Era's own categorization takes over again. Defaults to false. |
clearMerchantNameoptional | Drops your merchant-name override, so the name your bank sent comes back. Defaults to false. |
clearDescriptionoptional | Drops your description override, so the description your bank sent comes back. Defaults to false. |
clearReviewStatusoptional | Drops your review-status override. Defaults to false. |
Response · 200
{
"transactions": [ … ]
}
You get the updated transactions back, in the same shape the list above returns. Setting a field and clearing it in the same call comes back 400, and so does an empty list. A list holding a transaction that isn't yours, or isn't there at all, comes back 403 for the whole call — nothing is changed. If something else changed one of those rows while you were writing, you get 409: read them again and send them again.
Categories
Scope required
banking:readThe whole category taxonomy: every set of categories, with its sub-categories nested inside. The taxonomy is shared, not per-account.
Response · 200
{
"packs": [
{
"packSlug": "default",
"packName": "Era default categories",
"isDefault": true,
"categories": [
{
"projectionKey": "fcat_food_dining",
"categoryName": "Food & dining",
"isTopLevel": true,
"children": [ … ]
}
]
}
],
"meterLimit": 25,
"canCreateCustomCategories": true
}
Add a category
A user-defined category under an existing parent. Mutating, so it needs banking:write rather than banking:read.
Scope required
banking:writeslug | URL-safe identifier — lowercase letters, numbers, and hyphens, 2 to 50 characters. |
|---|---|
parentCategoryKey | The fcat_ key of the category this one nests under. |
name | Display name. |
descriptionoptional | Optional description. |
iconNameoptional | Optional icon name. |
spendingTypeoptional | Optional spending classification. |
displayOrderoptional | Optional sort position among its siblings. |
assignmentEligibilityoptional | Optional rule for which transactions this category can be assigned to. |
sourceSystemKeysoptional | Optional list of existing category keys whose transactions should be routed here going forward. |
applyRetroactivelyoptional | When true, re-evaluates past transactions against the new routing too. Defaults to false. |
Response · 201
{
"categoryKey": "fcat_side_hustle_9f2a",
"overlayProjectionKey": "fcov_9f2a1c",
"action": "created",
"isQuotaExceeded": false,
"createdMappingRuleKeys": [ … ],
…
}
The response also carries retroactiveAffectedCount, mergeSourcesHiddenCount, mergeSourcesTotalCount, and meterGate — fields this call shares with category merges and quota-limited creates, not shown here.