Integrating with FinMesh
FinMesh aggregates bank data from institutions that have no usable API and serves it over a Plaid-shaped interface: you register as a consumer, receive an access_token scoped to an item, and call /v1/transactions/sync with a cursor. A client written against Plaid works here with a base-URL change and the two differences called out below.
https://finmesh.yan.codesQuickstart
Five calls from nothing to a transaction list.
1. Get credentials. The operator registers your application in the FinMesh admin and hands you a
client_idand asecret. There is no self-service signup: this is one person’s financial data, and access is granted by them, deliberately, per consumer.2. Mint a link_token. The response includes a hosted
link_urlfor the operator to open.POST /v1/link/token/createcurl -X POST https://finmesh.yan.codes/v1/link/token/create \ -H 'Content-Type: application/json' \ -d '{ "client_id": "$CLIENT_ID", "secret": "$SECRET", "redirect_uri": "https://your.app/finmesh/callback" }'3. The operator picks accounts. They open
link_url, tick the accounts your application may read, and land back on yourredirect_uriwith?public_token=…. Omitredirect_uriand the hosted page prints the token for them to hand over instead.4. Exchange it. Once, for an
access_tokenthat does not expire — store it like a password.POST /v1/item/public_token/exchangecurl -X POST https://finmesh.yan.codes/v1/item/public_token/exchange \ -H 'Content-Type: application/json' \ -d '{ "client_id": "$CLIENT_ID", "secret": "$SECRET", "public_token": "$PUBLIC_TOKEN" }' # → { "access_token": "…", "item_id": "…", "request_id": "req_…" }5. Sync. No cursor means “from the beginning” — the initial full history.
POST /v1/transactions/synccurl -X POST https://finmesh.yan.codes/v1/transactions/sync \ -H 'Content-Type: application/json' \ -d '{ "client_id": "$CLIENT_ID", "secret": "$SECRET", "access_token": "$ACCESS_TOKEN", "count": 500 }'
Conventions
They hold for every endpoint, so they are stated once.
- Everything is POST with a JSON body. Including the reads. A
GETreturns405withAllow: POST. - Credentials travel in the body, not in headers.
client_idandsecretauthenticate the request;access_tokenscopes it to one item. There is noAuthorizationheader on the consumer plane. - Every response carries a
request_id. Quote it when reporting a problem — it is in the server log too. (The bare405for a wrong verb is the one exception; it never reached a handler.) - No CORS headers are sent, on purpose. Call
/v1from your server. Asecretthat has been in a browser is a leakedsecret. - Money is a JSON number in the account’s native currency, with
iso_currency_codealongside it. Native is authoritative;amount_usdis a convenience.
Two ways FinMesh differs from Plaid
- The sign is inverted. Plaid makes money leaving the account positive. FinMesh keeps the bank’s own convention: spending is negative, income is positive, and the signed sum of a statement adds up. If you are porting a Plaid client, this is the line to change.
- There is no
pending. The field exists and is alwaysfalse. Data arrives from exports and screenshots after the fact, so nothing here is an authorization hold.
Getting an access_token
Link picks accounts; it does not create them.
An item is a grant: “this consumer may read these accounts.” The accounts already exist — the operator registered them and has been feeding them CSVs and screenshots, usually for a while — so Link is a picker over real accounts rather than a credential capture. One consequence worth knowing: a grant can span institutions, so institution_id on an item is the single institution when there is one and null when there are several. The full set is always in institutions.
The operator can also skip the browser entirely and mint an item and its access_token straight from the admin, then hand you the token. That is the same object by a shorter path — if you were given an access_token directly, start at syncing.
Endpoints
All POST. All take client_id and secret; the ones marked item also take access_token.
| Path | Scope | Returns |
|---|---|---|
| /v1/link/token/create | consumer | link_token, link_url, expiration. Optional redirect_uri must be absolute. |
| /v1/item/public_token/exchange | consumer | access_token, item_id. Single-use. |
| /v1/item/get | item | Item metadata: institution, institutions, account_ids, status, delivery_mode, error. |
| /v1/item/remove | item | { "removed": true }. Revokes the item and its token. Transactions are untouched. |
| /v1/institutions/get | consumer | Institutions this instance covers, with supported_connectors. |
| /v1/accounts/get | item | Accounts the item was granted, with balances. |
| /v1/accounts/balance/get | item | The same shape, after recomputing balances from approved rows. |
| /v1/holdings/get | item | What a valued account is made of — one row per asset, with quantity and value_usd as strings. Ignorable unless you care about composition; the balance is already a single USD number on /accounts/get. |
| /v1/transactions/sync | item | added, modified, removed, next_cursor, has_more. |
| /v1/categories/get | consumer | The full PFC taxonomy as { primary, detailed } pairs. |
| /v1/webhook/test | consumer | Fires a test event. access_token optional — supply it to scope the test to an item. |
| /v1/events/poll | item | Relay mode: events, next_cursor, has_more. |
| /v1/events/ack | item | Relay mode: { "drained": n } up to the cursor. |
GET /v1/link/:link_token is the one exception to POST-only: it is the hosted page the operator opens in a browser, not an API call.
Syncing transactions
The endpoint that matters. Everything else is metadata.
cursor is an opaque watermark over an append-only mutation log. Omit it for the initial full sync, then persist next_cursor after each page you have committed — not when you receive it. Replaying a cursor returns the same page, so a crash mid-page costs you a repeat, never a gap.
let cursor = await store.readCursor(); // null on first run
for (;;) {
const page = await post("/v1/transactions/sync", { cursor, count: 500 });
await store.transaction(async (tx) => {
for (const t of page.added) await tx.upsert(t);
for (const t of page.modified) await tx.upsert(t);
for (const t of page.removed) await tx.delete(t.transaction_id);
await tx.writeCursor(page.next_cursor); // same commit as the rows
});
cursor = page.next_cursor;
if (!page.has_more) break;
}- Upsert, don’t insert. A transaction can appear in
addedand later inmodified. Within a single page only the last mutation for a transaction is reported, so a row added and removed inside one window arrives as a removal only. removedmeans “no longer yours to show”, which covers a transaction the operator deleted and an account whose grant was revoked. It is not a statement that the money never moved.- Only approved rows are ever visible. Every source — CSV, screenshot, manual, chain, broker — commits immediately, so a row appears on the next sync after it was collected. The exception is an asset the operator has not yet tracked: its rows stay held until they do, and then arrive as
added. - A new item backfills. Granting an account writes
addedfor its whole history, so your first sync returns years of data even though the item is seconds old. countdefaults to 100 and is capped at 500. Out-of-range values are clamped, not rejected.
Transaction
| Field | Type | Notes |
|---|---|---|
| transaction_id | string | Stable. The upsert key. |
| account_id | string | Always one you were granted. |
| date | YYYY-MM-DD | When the transaction occurred. |
| authorized_date | YYYY-MM-DD | null | Posted date when the source carries one. |
| name | string | The raw description as the bank wrote it. Never rewritten. |
| merchant_name | string | null | Cleaned merchant, when known. Null is common and not an error. |
| amount | number | Signed, native currency. Negative is money out — the opposite of Plaid. |
| iso_currency_code | string | The account's currency. |
| unofficial_currency_code | null | Present for Plaid shape-compatibility. Always null. |
| amount_usd | number | null | Converted at the transaction's historical rate, not today's. |
| fx_rate | number | null | The rate used, so amount_usd is reproducible. |
| fx_date | YYYY-MM-DD | null | The rate's date — the most recent prior publication across weekends and holidays. |
| personal_finance_category | object | null | { primary, detailed, confidence_level }. Null when the description was never classified. |
| pending | false | Always false. |
| source | "csv" | "screenshot" | "manual" | "chain" | No Plaid equivalent. A screenshot-derived row is a model reading a picture; treat it accordingly if that matters to you. A chain row is the settlement record itself. |
| quantity | string | null | Native units, on a valued account — 0.4 ETH behind a USD amount. A string, because 18 decimals do not survive JSON's number type. Null for ordinary fiat rows. |
| asset | string | null | The ticker `quantity` is denominated in. |
| price_usd | string | null | Unit price used to value the row, at the time it happened. |
| flow | "external" | "internal" | internal means it never left the operator's possession — a swap leg, or a transfer between two of their own accounts. Net these, do not drop them: the residue of a netted group is the spread, which is the real cost of the exchange. |
| group_key | string | null | One real-world event. A swap is three rows — asset out, asset in, gas — sharing one key. Sum amount_usd across a group to get what it actually cost you. |
Account
| Field | Type | Notes |
|---|---|---|
| account_id | string | Referenced by every transaction. |
| name | string | The operator's name for it. |
| official_name | string | null | The bank's, if recorded. |
| mask | string | null | Last digits. |
| type / subtype | string | Plaid-style, e.g. depository. |
| balances.current | number | null | Derived from approved rows — never entered. Null means unknown, not zero: no transaction on the account carries a running balance, so rows are probably missing. Do not coerce it to 0. |
| balances.available | number | null | Same derivation; null far more often than not. |
| balances.last_updated_datetime | ISO 8601 | null | When the balance was last computed. |
| connector_type | string | How data reaches the account: csv, screenshot, manual, chain, broker. |
Events & webhooks
Two transports, one payload. The operator chooses per consumer.
Event types
| Field | Type | Notes |
|---|---|---|
| TRANSACTIONS.SYNC_UPDATES_AVAILABLE | new_transactions | modified_transactions | A count, not the rows. Call /transactions/sync to collect them. |
| TRANSACTIONS.REMOVED | removed_transactions: string[] | Transaction ids you should drop. Also arrives via sync. |
| ITEM.NEW_ACCOUNTS_AVAILABLE | account_id, name | The operator granted the item another account. Its history is already backfilled. |
| ITEM.ERROR | error, message | Also the type used by /v1/webhook/test, with test: true. |
Every event body carries event_id, webhook_type, webhook_code, type, item_id, account_id, created_at, plus the payload fields above merged in at the top level.
Verifying a webhook
Sign over the raw body — re-serializing parsed JSON will not match. The signing secret is shown in the admin next to the webhook URL.
import crypto from "node:crypto";
const TOLERANCE_S = 300;
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=")),
);
const t = Number(parts.t);
if (!Number.isFinite(t)) return false;
// Bound replay: the timestamp is inside the signed string, so an attacker
// cannot re-date a captured delivery without invalidating it.
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_S) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(parts.v1 ?? ""),
);
}Draining the relay
let cursor = await store.readEventCursor();
const page = await post("/v1/events/poll", { cursor, count: 100 });
for (const event of page.events) await handle(event);
// Ack only what you have handled: everything up to this cursor is dropped
// from the queue, and the queue is the only copy in relay mode.
await post("/v1/events/ack", { cursor: page.next_cursor });
await store.writeEventCursor(page.next_cursor);Events are a nudge, not a feed
/transactions/sync on a timer and ignores events entirely is a perfectly correct consumer.Errors
One envelope, whatever went wrong.
{
"error_type": "INVALID_INPUT",
"error_code": "INVALID_ACCESS_TOKEN",
"error_message": "The access_token is invalid, revoked, or belongs to another consumer.",
"display_message": null,
"request_id": "req_9f2c1a4b7e0d3c58"
}| HTTP | error_code | Cause |
|---|---|---|
| 400 | INVALID_BODY | The body was not a JSON object. |
| 400 | MISSING_FIELDS | A required field was absent or empty. error_message names it. |
| 400 | INVALID_FIELD | A field was present but unusable — redirect_uri that is not an absolute URL. |
| 400 | INVALID_PUBLIC_TOKEN | Already exchanged, or never completed. Public tokens are single-use. |
| 400 | INVALID_ACCOUNT | An account in the request does not exist or is inactive. |
| 401 | INVALID_API_KEYS | Wrong client_id/secret, or the consumer was disabled. |
| 401 | INVALID_ACCESS_TOKEN | Revoked, rotated, or belonging to another consumer. Re-run Link. |
| 405 | METHOD_NOT_ALLOWED | You used GET. |
| 500 | INTERNAL_SERVER_ERROR | A bug. Retry is safe; then send the request_id. |
Authentication failures are deliberately indistinguishable: a wrong client_id and a wrong secret return the same message, and both are compared in constant time.
Categories
Plaid's Personal Finance Category taxonomy, verbatim — 16 primaries over 104 detailed categories.
Verbatim is the point: a client that already renders Plaid categories needs no mapping layer. Mapping PFC onto your own chart of accounts is your job, not FinMesh’s — the taxonomy here will never grow a FinMesh-specific category. Fetch the full list from /v1/categories/get; the primaries are:
confidence_level reflects how the classification was made — a learned rule table handles the common descriptions and a model handles the tail. Classification is also opt-in on this instance, so personal_finance_category being null is normal and means “not classified”, never “uncategorized spending”.
| Field | Type | Notes |
|---|---|---|
| link_token TTL | 30 minutes | Mint another; they are cheap. |
| public_token | single use | Exchanging it rotates the item's access_token. |
| access_token | no expiry | Ends only at /v1/item/remove, or when the operator rotates or revokes it. |
| sync count | default 100, max 500 | Values outside the range are clamped. |
| events/poll count | default 100, max 500 | Same clamping. |
| webhook attempt | 15s timeout | Then 1m, 5m, 30m, 2h, 12h, then failed. |
| rate limit | none | Single-operator instance. Be reasonable: a sync loop on a timer, not a spin loop. |
This instance serves one person’s financial data and is usually reachable only over their private network. If a call is timing out rather than failing, the likeliest answer is that you are off that network.