npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@coderoftime/fints

v0.3.0

Published

FinTS 3.0 client for German banks: accounts, balances and transactions with PIN/TAN, PSD2 strong customer authentication and decoupled app approval.

Downloads

475

Readme

@coderoftime/fints

A FinTS 3.0 client for German banks, written in TypeScript, without runtime dependencies. It reads accounts, balances and transactions with PIN/TAN, including PSD2 strong customer authentication and app approval (pushTAN, SecureGo plus and the like).

It is a port of the Dart package fints (see Differences from the Dart package). FinTS 4.1, which hardly any bank offers, is not part of the port.

Features

  • PIN/TAN with the one-step method (999) and two-step methods of process variant 2 (HKTAN process 4, then 2 or S).
  • Strong customer authentication at login and per transaction where the bank asks for it, including exemptions (3076).
  • App approval (decoupled), polled as the bank's parameters say, for 10 minutes at most; the client stops when the app is closed or the user cancels.
  • TAN media lookup and selection, chipTAN flicker codes (HHD 1.3 and 1.4, as frames to animate) and photoTAN/QR images (PNG, JPEG, GIF, WebP or BMP, as data: URLs for an <img>).
  • Balances (HKSAL 6–8) and transactions as camt.052 (HKCAZ) or MT940/MT942 (HKKAZ 6–7), with automatic paging.
  • IBAN and BIC from the SEPA account list (HKSPA), and the account extension of the user parameter data.
  • Safe by default: after a refused login the PIN is not sent again until the user enters it anew, also by a client restored from the saved state (see Refused logins); only what the bank's parameter data permit is sent; PIN and TAN are masked in every log.
  • Saved sessions: bank and user parameters, customer system ID, the chosen TAN method and a refused login in one state string, which spares the setup steps at the next start.
  • Parsers for MT940 (German structured :86: fields, SEPA keywords) and camt.052/053/054, statement by statement with the account each is for (Mt940.parse, Camt.parse), or as one list of transactions (parseTransactions).

Requirements

  1. A FinTS product registration. German banks only accept registered products. Register for free with the Deutsche Kreditwirtschaft at https://www.fints.org/de/hersteller/produktregistrierung and pass the ID as productId. Keep it out of your repository.
  2. Your bank's FinTS URL and bank code (BLZ), for example from the DK institute list.
  3. A runtime with fetch, Web Crypto and TextDecoder: Node.js from 22.12, Deno from 2.1, Bun from 1.2.6, or a browser behind a server of your own (see Using it with Vue). The type declarations use the global URL, AbortSignal and fetch types: include the TypeScript lib DOM or @types/node.

Usage

import { FinTs3Client, TanAnswer } from '@coderoftime/fints';

const client = new FinTs3Client({
  config: {
    url: 'https://fints.example-bank.de/fints',
    bankCode: '12345678',
    userId: 'my-login',
    productId: process.env.FINTS_PRODUCT_ID!,
    productVersion: '1.0',
  },
  pin,
  options: {
    state: savedState, // from a previous session, or undefined
    tanMethodSelector: (methods) => pickMethod(methods),
    tanMediumSelector: (media) => pickMedium(media),
    onBankMessage: (message) => showNotice(message.subject, message.text),
    onWarning: (warning) => showNotice(warning.code, warning.text), // banks want code and text shown
    tanCallback: async (challenge) => {
      if (challenge.isDecoupled) {
        // Close the prompt when challenge.resolved settles; the bank's new instructions come with the next call.
        // Offer "approved" only if challenge.manualConfirmation.
        const confirmed = await showApprovalPrompt(challenge);
        return confirmed ? TanAnswer.approved : TanAnswer.cancelled;
      }
      // Show challenge.plainMessage as text (textContent, never innerHTML), challenge.image?.toDataUrl() in an
      // <img>, or challenge.flickerCode?.frames().
      const tan = await askForTan(challenge);
      return tan === undefined ? TanAnswer.cancelled : TanAnswer.tan(tan);
    },
  },
});

try {
  const accounts = await client.getAccounts();
  const balance = await client.getBalance(accounts[0]!);
  const transactions = await client.getTransactions(accounts[0]!, { from: new Date(2024, 0, 1) });
} finally {
  try {
    await client.close();
  } finally {
    saveEncrypted(client.state); // after close, whose answers may change it; no PIN or TAN, but login and account data
  }
}

A call logs in where it needs to, which sends the PIN and may ask the callbacks for a TAN method, a TAN medium and an approval; so does getTanMethods of a client without saved state. Calls share one bank dialog while it is open, so the approval of its login serves them all. The next call logs in anew, which may ask for another approval, once the dialog has idled longer than the bank allows (BankInfo.maxTimeout, 5 minutes where it states none), after selectTanMethod and clearLoginRefusal, once the bank has ended the dialog, and after a message failed with another error than a FinTsBankError, such as a cancelled or timed-out approval, a failing tanCallback, or a lost or unreadable answer. A query may need an approval of its own, such as one for transactions older than 90 days, and so may up to three of its further pages; where a fourth asks for one, the call fails with FinTsProtocolError before the user is asked.

Calls run one after another. A callback must not wait for another call of its client, which would wait for the running call: on Node.js, Deno and Bun such a call fails with FinTsStateError, in browsers it waits forever.

A saved state belongs to one client at a time. Run the clients of one bank user one after another, each restored from the state the one before saved, for example behind a lock or a queue per user, never two at once from the same state, such as for two browser tabs: the state saved last wins, and could lift a refused login the other client saved.

Never log the client: JSON.stringify and Node.js's console.log leave out the PIN, which the client keeps in a private field until it is closed or the login refused, but a debugger and the browser's DevTools show it.

console.log and util.inspect on Node.js, Deno and Bun show the objects of this package only by what identifies no person, account or TAN: codes, kinds, counts, dates and the last digits of an IBAN or card number, such as Account(…3000, Girokonto), TanChallenge(TanMethod(942, pushTAN), decoupled, attempt 1) or FinTsFeedback(9010). They leave out the bank's texts, references and parameters, TAN media names, TANs, challenge data and images, login data, and the amounts of balances and transactions; an Amount on its own shows as its toString, such as -123.45 EUR. toString of a FinTsFeedback and of a BankMessage leaves out the bank's texts as well: show code and text, or subject and text, to the user. An error shows its message and stack, and a FinTsTransportError its statusCode, also where Node.js reports it uncaught; the message of a FinTsBankError, and so its stack, holds the bank's texts and parameters of its errors, such as an IBAN, so log its codes instead. JSON.stringify and the browser console show every field, IBANs, names, amounts, purposes and the TAN of a TanAnswer included, so that a server can pass the data to its own front end: neither belongs in logs. Only JSON.stringify of an error leaves out the feedback of a FinTsBankError and the source of a FormatError.

An account the app builds itself, such as new Account({ bankCode, iban }), stands for the account of getAccounts it matches by IBAN or account number, whose details and permitted transactions the queries take. One the bank names nowhere goes out with what identifies it only, and fails with FinTsUnsupportedError where the bank's lists of permitted transactions are complete.

Calendar dates, such as Transaction.bookingDate, are Dates at local midnight, of which only the day counts. Moments, such as Balance.bookedAsOfTime, are exact; the bank states them in German time.

Using it with Vue

A web page cannot talk to a bank's FinTS server directly. Browsers only let a page call another origin if the server allows it (CORS), and bank servers do not. So some server of yours always sits between the browser and the bank, and that server sees the PIN: FinTS PIN/TAN messages carry it in plain text inside TLS. It must be a server you and your users trust.

There are three ways to set it up:

  1. The client runs on your server (Nuxt server routes, Express, …). The Vue app calls your own API; the server keeps one FinTs3Client per session and passes the TAN challenges to the browser, for example with a pending request the browser answers. Two sessions of one bank user share the saved state, so run their clients one after another (see Usage). This keeps the bank traffic and the product ID on the server.

  2. The client runs in the browser, through a relay. The library runs in the Vue app, so TAN prompts are plain local UI, and a small endpoint of yours forwards each message to the bank. Pass a transport that posts to it:

    import { FinTsTransportError, type FinTsTransport } from '@coderoftime/fints';
    
    const relay: FinTsTransport = {
      async post(url, body, contentType, signal) {
        const response = await fetch('/api/fints-relay', {
          method: 'POST',
          headers: { 'Content-Type': contentType, 'X-FinTS-URL': url.href },
          body: body.slice(), // a copy on an ArrayBuffer of its own, the only kind TypeScript's fetch types take
          signal, // aborted five seconds after the client is closed
          redirect: 'error', // never send the PIN on to another address
          cache: 'no-store',
        });
        if (!response.ok) {
          // The bank's HTTP status, which the relay passes on and the client acts on (FinTsTransport.post).
          throw new FinTsTransportError(`Relay: HTTP ${response.status}`, { statusCode: response.status });
        }
        return new Uint8Array(await response.arrayBuffer());
      },
      close() {},
    };

    Until it is closed, the client limits neither the time of an exchange nor the size of an answer, so a transport of your own has to, unless it goes through a relay that does, as this one does. The relay forwards with HttpTransport, which times out, limits the size of the answer and follows no redirect, and passes the bank's HTTP status on. A sketch with the web-standard Request and Response:

    import { FinTsTransportError, HttpTransport } from '@coderoftime/fints';
    
    const bank = new HttpTransport();
    const maxBodyBytes = 1024 * 1024; // far more than a message of the client takes
    
    // Behind the app's login and a rate limit per session; bankUrl is the bank the user chose, as URL.href writes it.
    export async function relay(request: Request, session: { bankUrl: string }): Promise<Response> {
      if (request.headers.get('X-FinTS-URL') !== session.bankUrl) return new Response(null, { status: 403 });
      // Checked before the body is read, of which the server reads no more than the length states.
      const length = Number(request.headers.get('Content-Length') ?? NaN);
      if (!(length <= maxBodyBytes)) return new Response(null, { status: 413 });
      try {
        const body = new Uint8Array(await request.arrayBuffer());
        const answer = await bank.post(new URL(session.bankUrl), body, 'text/plain', request.signal);
        return new Response(answer.slice(), { headers: { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' } });
      } catch (error) {
        // The bank's HTTP status, which the client acts on; 502 where the bank did not answer.
        const status = error instanceof FinTsTransportError ? error.statusCode : undefined;
        return new Response(null, { status: status ?? 502 });
      }
    }

    Anyone who can call the relay can send logins to the banks it forwards to, from your server's address, which a bank may then block for all your users. So serve it to the signed-in sessions of your app only, limit the requests of each session, and forward only to the bank the user chose for the session, from a list of known https URLs such as the DK institute list. The body holds PIN and TAN: never log it, on either side, nor put it in an error message, which the client passes on. And never mix up the answers of two sessions: the client cannot tell the answer to the first message of a dialog from that of another user. The product ID ends up in the page; anyone can read it there.

  3. The app is not a web page: Electron, Tauri or Capacitor can send requests without CORS. Pass a FinTsTransport that uses the platform's HTTP client without following redirects, with a timeout and a limit on the size of the answer, or HttpTransport where fetch is not bound by CORS.

Keep a client in the Vue app out of reactive state (ref, reactive, a Pinia store): Vue wraps it in a proxy there, which cannot reach the client's private fields, so every call fails with a TypeError. A plain variable, a shallowRef or markRaw(client) keeps the client as it is.

A TAN prompt in Vue fits the callback as a promise the dialog resolves:

import { ref } from 'vue';
import { TanAnswer, type TanChallenge } from '@coderoftime/fints';

export const pending = ref<{ challenge: TanChallenge; answer: (a: TanAnswer) => void }>();

export function tanCallback(challenge: TanChallenge): Promise<TanAnswer> {
  return new Promise((answer) => {
    pending.value = { challenge, answer };
    void challenge.resolved?.then(() => (pending.value = undefined)); // the client needs no answer any more
  });
}
// The dialog component shows pending.value.challenge and calls pending.value.answer(TanAnswer.tan(input)),
// TanAnswer.approved or TanAnswer.cancelled, then clears pending.value.

The dialog shows the challenge's text with {{ }}, which Vue escapes, never with v-html: plainMessage and message, like any text of the bank, are no markup to trust, and as HTML they could run scripts in the page where the user enters the PIN. The same holds for the bank messages and warnings of onBankMessage and onWarning, whose number and length the bank chooses without a limit, so bound what the app shows of them. white-space: pre-line keeps the line breaks of plainMessage:

<div v-if="pending">
  <p style="white-space: pre-line">{{ pending.challenge.plainMessage }}</p>
  <img v-if="pending.challenge.image" :src="pending.challenge.image.toDataUrl()" alt="TAN image" />
</div>

Errors

| Error | Meaning | | ---------------------------- | --------------------------------------------------------------------------------------------------------------------- | | FinTsLoginRefusedError | The bank refused the login. The client sends the PIN no more, see Refused logins. | | FinTsPinError | Wrong or blocked PIN, a FinTsLoginRefusedError. | | FinTsTanError | Wrong, expired or used TAN. The session stays usable. | | FinTsTanCancelledError | The user cancelled the TAN or the app approval. | | FinTsDecoupledTimeoutError | The app approval did not come within the requests the bank allows (60 where it states none) or 10 minutes. | | FinTsLoginDataChangedError | The bank issued new login data (3072); loginName tells it. A FinTsLoginRefusedError for the old data. | | FinTsDialogAbortedError | The bank ended the dialog; the next call logs in anew. | | FinTsBankError | Any other rejection; feedback holds the return codes. | | FinTsUnsupportedError | The parameter data do not offer the transaction; the client did not send it, though it may have logged in. | | FinTsTanSetupError | No TAN method or medium to log in with; pass a medium to selectTanMethod, or choose another method. | | FinTsProtocolError | The bank's answer could not be read. The message leaves out the data, which may be account data. | | FinTsTransportError | No connection, an HTTP error (statusCode), a timeout or an abort by close. The message may have reached the bank. | | FinTsStateError | The client was closed, or called from inside one of its callbacks. | | TypeError | An invalid argument, such as an empty PIN or a TAN that the TAN method rules out; the message never shows PIN or TAN. |

The client never repeats a call by itself: the bank's answer cannot tell whether an order went through.

It reads only complete answers to the message it sent: one cut short, broken, or with the number or dialog ID of another message fails the call with FinTsProtocolError. Only the bank's dialog abort message, which names neither (dialog ID unbekannt, message number 9999), answers any message: it fails the call with a FinTsBankError that holds the bank's text, and ends the dialog. The first message of every dialog goes out with the same number and dialog ID, though, so the answer to another user's first message passes: a relay must never mix up answers. A query whose pages hold more than 128 MiB, each data element counted as 512 bytes, or more than 200,000 statements and transactions, fails with FinTsProtocolError as well: ask for a shorter period then. So that a broken or hostile server cannot block the app or exhaust its memory, the client also refuses an answer with more than 1,000,000 data elements and segments in its plain or in its encrypted part, or with more than one encrypted part; XML nested more than 100 levels deep, or with more than 500,000 nodes besides text or one per 16 characters, whichever is more; an MT940 field or camt remittance information (Ustrd) of more than 65,536 characters; and an amount written with more than 64 characters. Mt940.parse, Camt.parse and Amount.parseDecimal throw FormatError for these.

MT940 statements must balance. Where a bank wraps a purpose at a fixed width, a payer could start a line of it like a field, such as :61:, which would add a transaction; getTransactions fails with FinTsProtocolError instead, and Mt940.parse with FormatError. The check misses such lines that add up to nothing, and statements without balances, such as pending transactions (MT942).

Refused logins

Banks lock the online access after a few failed logins. So once the bank has refused a login, the client does not send the PIN again: every call that would fails with the refusal, a FinTsLoginRefusedError, without contacting the bank. Any error in the answer to a login refuses it, since a return code does not always tell whether the PIN was the reason, and so does a wrong PIN the bank reports later in the dialog.

Only where the bank names a reason the client can take care of does the same call log in once more, once per reason: with another TAN method where the bank lists those the user may use without the one the login was signed with (3920, after the one-step method only next to no error but 9050, 9800 or 9955) or demands strong authentication of a one-step login (9075), and with a new customer system ID where the bank rejects the old one (9391). None of them follows a wrong PIN, and a call sends at most three logins the bank refuses.

A login whose answer is lost, times out, is aborted by close or cannot be read may have counted as a failed attempt all the same. After two such logins in a row the client refuses the login itself, with a FinTsLoginRefusedError whose feedback is empty; it cannot tell a login that never reached the bank, so a connection that fails twice in a row stops it as well. A login the bank takes clears the count.

The refusal and that count are part of client.state, so a client restored from it does not send the PIN either, as long as the clients of the user take turns with the state (see Usage); where their part of the state is damaged, the client restores a refusal whose feedback is empty. client.loginRefusal tells it before any call. Lift it with a PIN the user has entered anew, unless the bank has changed the login data:

const refusal = client.loginRefusal;
if (refusal instanceof FinTsLoginDataChangedError) {
  // The old login data are retired: build a new client with refusal.loginName as userId, or its userId and customerId.
} else if (refusal !== undefined) {
  showNotice(refusal.message); // the bank's errors, or why the client stopped by itself
  await client.clearLoginRefusal(await askForPin());
}

Never pass clearLoginRefusal a stored PIN without asking the user: that is how a retrying app locks the access. It also forgets the TAN medium, which the bank may have refused instead, such as that of a replaced phone, so that the next call looks the media up anew.

Differences from the Dart package

This section compares with the Dart package as its source stands after its release 0.2.0, which differs in more ways. Both keep the saved state in the same layout, a refused login included, so each restores the other's. The Dart package 0.2.0 keeps no refused login, though: restored from a state of this package, it sends the PIN the bank refused. They differ in:

  • Errors are named …Error instead of …Exception; invalid arguments are TypeErrors, malformed data FormatErrors, and a closed client or a call from inside a callback a FinTsStateError. Some messages are worded differently.
  • getTransactions(account, { from, to, format }) takes an options object; enums are string unions such as 'mt940'.
  • The polling parameters of TanMethod are in seconds: decoupledFirstPollDelaySeconds, decoupledPollIntervalSeconds.
  • No FinTS 4.1, so no FinTs4Client, FinTsClient.version or BankInfo.bic. FinTsConfig checks the login data against what FinTS 3.0 can send, which the Dart package does in FinTs3Client, and also refuses a user ID or customer ID of more than 30 characters, which the Dart package sends.
  • HttpTransport takes a fetch and timeoutMs instead of an HTTP client and a Duration. FinTsTransport.post takes an AbortSignal, with which close aborts a request on its way after five seconds, also through a custom transport, and then stops waiting for it whatever the transport does; the Dart package aborts only its own HttpTransport (abortRequests) and waits for any other transport up to that transport's timeout.
  • HTTP goes through the runtime's fetch, with its proxies, timeouts and trusted certificates. Plain http to a local server, which allowInsecureUrl allows, goes wherever the global fetch sends it, as https does: to whatever replaces globalThis.fetch, such as a test's stub, to a global dispatcher on Node.js, and through any proxy that fetch takes from the environment (Node.js with NODE_USE_ENV_PROXY=1, Deno and Bun with HTTP_PROXY), which sees the PIN. Exempt the host with NO_PROXY=localhost,127.0.0.1,::1 or pass a fetch that connects directly. The Dart package connects to the loopback host directly, also bypassing a client set with runWithClient, and on native platforms follows HTTPS_PROXY and NO_PROXY for https, which Node.js does only with NODE_USE_ENV_PROXY=1.
  • A call from inside a callback fails on Node.js, Deno and Bun only; in browsers it waits forever, where the Dart package fails on every platform.
  • console.log and util.inspect show the objects of this package as Usage describes, where the Dart package prints most of them as Instance of '…', and toString of a FinTsFeedback or a BankMessage leaves out the bank's texts, which the Dart package prints. The browser console shows every field, where the Dart package on the web prints toString.
  • JSON.stringify gives every field of a model, the TAN of a TanAnswer included, and of an error its name, and the statusCode of a FinTsTransportError; the Dart package's models and errors have no JSON form.
  • Some bank data are read and sent otherwise. In MT940, the counterparty's IBAN and BIC come from the subfields ?31 and ?30 only, not from IBAN+ and BIC+ in the payer's reference, a bank code in ?30 next to an IBAN is the bank code, the customer reference KREF+ stands for the one of :86:, and the balance date 0 or 000000 for none. A camt purpose falls back to the structured creditor reference, and remittance information of more than 65,536 characters is refused. Challenge text counts as a combined chipTAN challenge only where it starts with CHLGUC and states the length of its code. The bank's dialog abort message fails the call with a FinTsBankError. A balance or transaction query that names the account by its international account (KTI) leaves out the national account where the bank parameter data rule it out, unless the account has no BIC, and sends no IBAN without its BIC, unless the account has no account number. A one-step login that the bank answers with 9955 and 3920 is repeated with a two-step method.

Development

npm install
npm run check   # type check, lint, unused code, format check and tests with coverage
npm run build

The check fails for Node.js APIs in src/ that browsers lack, for files, exports and dependencies nothing uses, or only the tests (knip), and for coverage below the thresholds in vitest.config.ts, which each module that guards what goes to and comes from the bank has to reach on its own. The coverage report is in coverage/index.html.

License

BSD 3-Clause, see LICENSE. Commercial use is allowed; a product that ships the library must reproduce its copyright notice and license in its documentation or notices.