Consumer API · v1

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.

Base URLhttps://finmesh.yan.codes

Quickstart

Five calls from nothing to a transaction list.

  1. 1. Get credentials. The operator registers your application in the FinMesh admin and hands you a client_id and a secret. There is no self-service signup: this is one person’s financial data, and access is granted by them, deliberately, per consumer.

  2. 2. Mint a link_token. The response includes a hosted link_url for the operator to open.

    POST /v1/link/token/create
    curl -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. 3. The operator picks accounts. They open link_url, tick the accounts your application may read, and land back on your redirect_uri with ?public_token=…. Omit redirect_uri and the hosted page prints the token for them to hand over instead.

  4. 4. Exchange it. Once, for an access_token that does not expire — store it like a password.

    POST /v1/item/public_token/exchange
    curl -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. 5. Sync. No cursor means “from the beginning” — the initial full history.

    POST /v1/transactions/sync
    curl -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 GET returns 405 with Allow: POST.
  • Credentials travel in the body, not in headers. client_id and secret authenticate the request; access_token scopes it to one item. There is no Authorization header on the consumer plane.
  • Every response carries a request_id. Quote it when reporting a problem — it is in the server log too. (The bare 405 for a wrong verb is the one exception; it never reached a handler.)
  • No CORS headers are sent, on purpose. Call /v1 from your server. A secret that has been in a browser is a leaked secret.
  • Money is a JSON number in the account’s native currency, with iso_currency_code alongside it. Native is authoritative; amount_usd is 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 always false. Data arrives from exports and screenshots after the fact, so nothing here is an authorization hold.

Endpoints

All POST. All take client_id and secret; the ones marked item also take access_token.

PathScopeReturns
/v1/link/token/createconsumerlink_token, link_url, expiration. Optional redirect_uri must be absolute.
/v1/item/public_token/exchangeconsumeraccess_token, item_id. Single-use.
/v1/item/getitemItem metadata: institution, institutions, account_ids, status, delivery_mode, error.
/v1/item/removeitem{ "removed": true }. Revokes the item and its token. Transactions are untouched.
/v1/institutions/getconsumerInstitutions this instance covers, with supported_connectors.
/v1/accounts/getitemAccounts the item was granted, with balances.
/v1/accounts/balance/getitemThe same shape, after recomputing balances from approved rows.
/v1/holdings/getitemWhat 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/syncitemadded, modified, removed, next_cursor, has_more.
/v1/categories/getconsumerThe full PFC taxonomy as { primary, detailed } pairs.
/v1/webhook/testconsumerFires a test event. access_token optional — supply it to scope the test to an item.
/v1/events/pollitemRelay mode: events, next_cursor, has_more.
/v1/events/ackitemRelay 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.

the loop
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 added and later in modified. 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.
  • removed means “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 asadded.
  • A new item backfills. Granting an account writes added for its whole history, so your first sync returns years of data even though the item is seconds old.
  • count defaults to 100 and is capped at 500. Out-of-range values are clamped, not rejected.

Transaction

FieldTypeNotes
transaction_idstringStable. The upsert key.
account_idstringAlways one you were granted.
dateYYYY-MM-DDWhen the transaction occurred.
authorized_dateYYYY-MM-DD | nullPosted date when the source carries one.
namestringThe raw description as the bank wrote it. Never rewritten.
merchant_namestring | nullCleaned merchant, when known. Null is common and not an error.
amountnumberSigned, native currency. Negative is money out — the opposite of Plaid.
iso_currency_codestringThe account's currency.
unofficial_currency_codenullPresent for Plaid shape-compatibility. Always null.
amount_usdnumber | nullConverted at the transaction's historical rate, not today's.
fx_ratenumber | nullThe rate used, so amount_usd is reproducible.
fx_dateYYYY-MM-DD | nullThe rate's date — the most recent prior publication across weekends and holidays.
personal_finance_categoryobject | null{ primary, detailed, confidence_level }. Null when the description was never classified.
pendingfalseAlways 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.
quantitystring | nullNative 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.
assetstring | nullThe ticker `quantity` is denominated in.
price_usdstring | nullUnit 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_keystring | nullOne 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

FieldTypeNotes
account_idstringReferenced by every transaction.
namestringThe operator's name for it.
official_namestring | nullThe bank's, if recorded.
maskstring | nullLast digits.
type / subtypestringPlaid-style, e.g. depository.
balances.currentnumber | nullDerived 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.availablenumber | nullSame derivation; null far more often than not.
balances.last_updated_datetimeISO 8601 | nullWhen the balance was last computed.
connector_typestringHow data reaches the account: csv, screenshot, manual, chain, broker.

Events & webhooks

Two transports, one payload. The operator chooses per consumer.

webhook
FinMesh POSTs to your URL. For a consumer with a reachable endpoint.

Headers: FinMesh-Timestamp and FinMesh-Signature: t=<unix>,v1=<hex>.

Any 2xx is success. Anything else retries at 1m, 5m, 30m, 2h, 12h, then the delivery is marked failed and surfaced to the operator. Requests time out after 15s.

relay
Events queue and you drain them. For a consumer that binds to localhost or is often not running.

/v1/events/poll returns events after your cursor; /v1/events/ack marks everything up to a cursor drained. Ack is idempotent.

Same event bodies as webhook mode, so moving between the two needs no handler change.

Event types

FieldTypeNotes
TRANSACTIONS.SYNC_UPDATES_AVAILABLEnew_transactions | modified_transactionsA count, not the rows. Call /transactions/sync to collect them.
TRANSACTIONS.REMOVEDremoved_transactions: string[]Transaction ids you should drop. Also arrives via sync.
ITEM.NEW_ACCOUNTS_AVAILABLEaccount_id, nameThe operator granted the item another account. Its history is already backfilled.
ITEM.ERRORerror, messageAlso 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.

node
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

the loop
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

Treat every event as “something changed, go sync.” A missed webhook costs you latency and nothing else — the mutation log is the source of truth and it is still there. A consumer that polls /transactions/sync on a timer and ignores events entirely is a perfectly correct consumer.

Errors

One envelope, whatever went wrong.

error envelope
{
  "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"
}
HTTPerror_codeCause
400INVALID_BODYThe body was not a JSON object.
400MISSING_FIELDSA required field was absent or empty. error_message names it.
400INVALID_FIELDA field was present but unusable — redirect_uri that is not an absolute URL.
400INVALID_PUBLIC_TOKENAlready exchanged, or never completed. Public tokens are single-use.
400INVALID_ACCOUNTAn account in the request does not exist or is inactive.
401INVALID_API_KEYSWrong client_id/secret, or the consumer was disabled.
401INVALID_ACCESS_TOKENRevoked, rotated, or belonging to another consumer. Re-run Link.
405METHOD_NOT_ALLOWEDYou used GET.
500INTERNAL_SERVER_ERRORA 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:

INCOMETRANSFER_INTRANSFER_OUTLOAN_PAYMENTSBANK_FEESENTERTAINMENTFOOD_AND_DRINKGENERAL_MERCHANDISEHOME_IMPROVEMENTMEDICALPERSONAL_CAREGENERAL_SERVICESGOVERNMENT_AND_NON_PROFITTRANSPORTATIONTRAVELRENT_AND_UTILITIES

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

FieldTypeNotes
link_token TTL30 minutesMint another; they are cheap.
public_tokensingle useExchanging it rotates the item's access_token.
access_tokenno expiryEnds only at /v1/item/remove, or when the operator rotates or revokes it.
sync countdefault 100, max 500Values outside the range are clamped.
events/poll countdefault 100, max 500Same clamping.
webhook attempt15s timeoutThen 1m, 5m, 30m, 2h, 12h, then failed.
rate limitnoneSingle-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.