Skip to content

Repository files navigation


🏦  flinks

The modern, fully-typed Flinks API client for Node.js & Bun

Every product. Every endpoint. Zero runtime dependencies.


npm TypeScript Runtime

CI Dependencies Bundle Tests Coverage License

Note

Community project — not affiliated with, endorsed by, or maintained by Flinks. "Flinks" is a trademark of its owner; this is an independent, unofficial client.


✨ Why this exists

The Flinks REST API is powerful but sprawling — seven products across three hosts, three auth schemes, PascalCase here and snake_case there, and an async 202-then-poll dance on every heavy endpoint. flinks hides all of that behind one typed client so you write less code and hit fewer edge cases.

🧩 Complete coverage Connect · Authorize · Enrich · Identity · Upload · Pay · Open Banking · Utilities
Async, solved getAccountDetails() runs authorize → MFA → 202-poll → data in one call
🔤 Idiomatic casing you speak camelCase; PascalCase/snake_case on the wire is handled
🛡️ Typed errors every failure is a FlinksError with a stable flinksCode + plain-English description
🔁 Safe & resilient backoff retries on 429/5xx — never silently retries a payment POST
🔐 Webhooks built in constant-time HMAC verify + typed events
⚛️ Next.js & React secure server route + typed browser client + Connect-widget hook
Redis adapter connect bank ASAP → store loginId → fetch transactions later
🪶 Tiny & fast native fetch, zero core dependencies, tree-shakeable ESM + CJS

📦 Install

bun add @rizwannur/flinks        # or: npm i @rizwannur/flinks  ·  pnpm add @rizwannur/flinks

Requires Node 18+ or Bun (anything with a global fetch).

🚀 Try it right now (public sandbox)

Flinks publishes a shared Toolbox sandbox. This exact snippet runs as-is:

import { FlinksClient } from '@rizwannur/flinks';

const flinks = new FlinksClient({
  instance: 'toolbox',
  customerId: '43387ca6-0391-4c82-857d-70d95f087ecb',
  secretKey: 'c4569c54-e167-4d34-8de6-f4113bc82414', // mints authorize tokens
  xApiKey: '3d5266a8-b697-48d4-8de6-52e2e2662acc',   // data endpoints
});

// The entire flow — authorize, MFA, poll, fetch — in ONE call:
const result = await flinks.getAccountDetails(
  { username: 'greatday_nomfa', password: 'Everyday', institution: 'FlinksCapital' },
  { detail: { withTransactions: true } },
);

if (result.status === 'done') {
  for (const a of result.accounts) {
    console.log(a.title, a.balance.current, a.currency, `${a.transactions?.length ?? 0} tx`);
  }
}

bun run examples/quickstart.ts runs it; bun run test:sandbox runs the live suite.

⚡ Quickstart (your account)

const flinks = new FlinksClient({
  instance: 'toolbox',            // toolbox | sandbox | production
  customerId: 'your-customer-guid',
  secretKey: 'your-secret-key',   // flinks-auth-key on GenerateAuthorizeToken
  xApiKey: 'your-x-api-key',      // x-api-key on data endpoints
});

One call, or step by step

The one-call helper handles the authorize token, the 202→poll wait, and the 203 MFA branch for you:

let res = await flinks.getAccountDetails({ loginId }); // from Flinks Connect
while (res.status === 'mfa') {
  const answers = await askUser(res.challenges);        // { [prompt]: [answer] }
  res = await res.answer(answers);
}
console.log(res.accounts);

Prefer the raw endpoints? They're all still there:

const auth = await flinks.authorize.authorize({ loginId });   // token minted for you
const detail = await flinks.connect.getAccountsDetailAndWait({ // 202→poll handled
  requestId: auth.requestId,
  withTransactions: true,
});

How auth works (so you're never confused)

Flinks uses two keys, and this library routes each to the right place automatically:

Key Header Used on
Secret key flinks-auth-key GenerateAuthorizeToken (mints authorize tokens)
Authorize token flinks-auth-key Authorize (minted + cached for you)
x-api-key x-api-key all data endpoints (accounts, statements, enrich)
HMAC secret verifying inbound webhooks

⚛️ Next.js & React — the whole integration in ~15 lines

Your API secret must never touch the browser. This library gives you a secure server route and a typed browser client that talks to it — so your React code calls Flinks methods directly, and the secret stays on the server.

1. Server route (app/api/flinks/route.ts):

import { createFlinksHandler } from '@rizwannur/flinks/next';

export const { POST } = createFlinksHandler({
  instance: 'toolbox',
  customerId: process.env.FLINKS_CUSTOMER_ID!,
  secretKey: process.env.FLINKS_SECRET_KEY!,
  xApiKey: process.env.FLINKS_X_API_KEY!,
  // Only these methods are reachable from the browser:
  allow: ['authorize.authorize', 'connect.getAccountsDetailAndWait'],
});

2. Browser client — same methods, same types, zero fetch boilerplate:

'use client';
import { createFlinksClient } from '@rizwannur/flinks/react';

const flinks = createFlinksClient(); // POSTs to /api/flinks

const detail = await flinks.connect.getAccountsDetailAndWait({ requestId });
//    ^ fully typed — autocomplete and return types, in the browser

3. Let users link their bank with the Connect widget hook. Mint the authorize token on your server (never ship the secret key), pass it to the widget, and receive the loginId when the user finishes:

'use client';
import { useFlinksConnect } from '@rizwannur/flinks/react';

export function LinkBank({ authorizeToken }: { authorizeToken: string }) {
  const { iframeUrl } = useFlinksConnect({
    instance: 'toolbox',
    authorizeToken,      // from GenerateAuthorizeToken on your server
    demo: true,          // sandbox only — shows the Flinks Capital test bank
    theme: 'system',     // light | dark | system
    // resumeSelectedAccounts: ['acct-1'], // reconnect: skip account picker
    onSuccess: ({ loginId }) => {
      // POST loginId to your API → flinks.getAccountDetails({ loginId })
    },
  });
  return <iframe src={iframeUrl} width="100%" height={600} />;
}

The full loop: server mints the token (generateAuthorizeToken) → widget links the bank and returns a loginIdserver calls getAccountDetails({ loginId }).

Don't want to fetch the token yourself? Point the hook at a backend route that mints one and it handles the round-trip for you:

const { iframeUrl } = useFlinksConnect({
  instance: 'toolbox',
  tokenEndpoint: '/api/flinks/token', // POST → { token }; minted on mount
  onSuccess: ({ loginId }) => linkAccount(loginId),
});

The /next handler uses only web-standard Request/Response, so the same one-liner works in Remix, Hono, Bun.serve, and edge runtimes too.

⚡ Redis — connect bank ASAP, store it, fetch transactions later

The Connect widget returns a loginId as soon as the user links their bank. That id is the connection. Store it immediately; pull accounts/transactions whenever you need them (background job — Flinks can take minutes).

bun add ioredis   # optional peer — only needed when you pass { url }
import Redis from 'ioredis';
import { createFlinksRedis } from '@rizwannur/flinks/redis';

const adapter = await createFlinksRedis({
  flinks: {
    instance: 'toolbox',
    customerId: process.env.FLINKS_CUSTOMER_ID!,
    secretKey: process.env.FLINKS_SECRET_KEY!,
    xApiKey: process.env.FLINKS_X_API_KEY!,
  },
  redis: new Redis(process.env.REDIS_URL!), // you bring Redis
  // 1) Store the connection yourself
  onConnected: async ({ loginId, meta }) => {
    await db.bankLinks.upsert({ loginId, userId: meta?.userId });
  },
  // 2) Persist accounts/transactions when a later fetch finishes
  onJobComplete: async (job) => {
    if (job.type === 'fetch-accounts' && job.status === 'completed') {
      await db.accounts.replaceForLogin(job.loginId, job.result);
    }
  },
});

// ASAP — widget onSuccess → your API (no Flinks polling here)
await adapter.connect({ loginId, meta: { userId } });

// Later — cron, button, webhook, whatever — use the stored loginId
await adapter.fetchTransactions(loginId);
await adapter.processJobs(); // worker

HTTP helper (Next / Hono / Bun.serve):

import { createFlinksRedis, createFlinksRedisHandler } from '@rizwannur/flinks/redis';

const adapter = await createFlinksRedis({ /* … */ });
export const { GET, POST } = createFlinksRedisHandler(adapter);
// POST action=token | connect | fetch | answer | process
// GET  action=job&id=…

Optional: register more processors (processors.enrichAttributes, custom keys) via createFlinksRedis({ processors }) and enqueue(loginId, type). Core @rizwannur/flinks stays zero-dependency — Redis is only on /redis.

⏳ The async flow, done right

Heavy Flinks endpoints reply 202 OPERATION_PENDING while the bank is being read, and expect you to poll a companion endpoint every 10 seconds for up to 30 minutes. You never have to write that loop:

// One call. Polls internally until the data is ready (or times out).
const summary = await flinks.connect.getAccountsSummaryAndWait(
  { requestId },
  { intervalMs: 10_000, timeoutMs: 30 * 60_000 }, // defaults shown
);

Prefer to drive it yourself? The low-level pieces are all public:

import { poll, isPending } from '@rizwannur/flinks';

const first = await flinks.connect.getAccountsDetail({ requestId });
const done = isPending(first)
  ? await poll(() => flinks.connect.getAccountsDetailAsync(requestId))
  : first;

🔐 Handling MFA

When a bank challenges the login, authorize() returns httpStatusCode: 203 with securityChallenges. Answer them by calling authorize() again with the same requestId:

let res = await flinks.authorize.authorize({ loginId });

while (res.httpStatusCode === 203) {
  const answers = await promptUser(res.securityChallenges!); // your UI
  res = await flinks.authorize.authorize({
    requestId: res.requestId,
    securityResponses: answers,
  });
}

🛡️ Typed errors

import { FlinksError } from '@rizwannur/flinks';

try {
  await flinks.connect.getAccountsSummary({ requestId });
} catch (err) {
  if (err instanceof FlinksError) {
    err.flinksCode;        // 'INVALID_LOGIN'
    err.httpStatusCode;    // 401
    err.description;       // 'The provided LoginId, username, or password is invalid.'
  }
}

A request that exceeds timeoutMs throws a typed FlinksTimeoutError (distinct from a caller cancellation), and every call accepts an AbortSignal so you can cancel it yourself:

import { FlinksTimeoutError } from '@rizwannur/flinks';

const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);
try {
  await flinks.connect.getAccountsDetailAndWait({ requestId }, { signal: controller.signal });
} catch (err) {
  if (err instanceof FlinksTimeoutError) { /* the request itself timed out */ }
}

🪝 Webhooks

Registering: Flinks has no self-serve webhook API. You enable webhooks by opening a Flinks Support ticket with your webhook URL and instance; Flinks configures the callback server-side. Delivery: any non-200 response is a failure, retried up to 10× at 30-min intervals — so verify fast and return 200.

Receiving: verify the HMAC-SHA256 signature (sent in the flinks-authenticity-key header) and get a typed, camelCased event:

import { handleFlinksWebhook } from '@rizwannur/flinks';

export async function POST(req: Request) {
  const raw = await req.text(); // exact bytes — never re-serialize
  let event;
  try {
    event = handleFlinksWebhook(raw, req.headers, process.env.FLINKS_HMAC_SECRET!);
  } catch {
    return new Response('bad signature', { status: 403 });
  }

  switch (event.responseType) {
    case 'GetAccountsDetail': /* full account payload ready */ break;
    case 'KYC':              /* holder identity fetched */    break;
    case 'PayEvent':         /* payment status update */       break;
    case 'UploadFraudAlert': /* fraud detected */              break;
  }
  return new Response('ok'); // must be 200 or Flinks retries
}

Verification is constant-time, so signatures can't be brute-forced by timing. Your custom Tag and the Flinks loginId come through on the event for correlating to your own records.

🧩 Product coverage

Every namespace hangs off the one client:

Namespace Covers
flinks.authorize generateAuthorizeToken, authorize (incl. MFA)
flinks.connect accounts summary & detail (sync, async, and *AndWait), statements, MFA questions, delete card, institutions, routing-number lookup, scheduled/nightly refresh
flinks.enrich income, credit-risk, lending, user-analysis & business attributes, request-specific attributes, categorization, prepayment optimization, attribute libraries, categories
flinks.identity fieldMatch — verify name/address/email/phone against bank-verified data
flinks.upload attribute upload, categorization, fraud analysis
flinks.pay ⚠️ V2 sessions (createSession / details / cancel / GEFT confirm) + EFT V1 (createEftTransaction, schedules, PAD, contacts) + legacy Interac V1. Experimental — set hosts.pay (+ payClientId for EFT V1)
flinks.outbound Open Banking — token, providers (v1/v2), recipients, registrations, revoke, FDX accounts/transactions/customers
flinks.utilities data-sharing authSecret grant / disable / enable, getPartnerData, partnerAccess
flinks.wealth investments (get/delete) — deprecated, retires 2026-04-30

Plus the top-level getAccountDetails / getAccountSummary one-call helpers, and webhook verification (flinks.webhooks.handle or the standalone handleFlinksWebhook).

⚠️ Flinks Pay is experimental. Prefer V2 (createSession / getSessionDetails). Legacy V1 Interac helpers remain. Pay runs on a client-provisioned host from onboarding — supply it via hosts.pay. Other products use standard Flinks hosts.

⚙️ Configuration

new FlinksClient({
  instance: 'toolbox',      // required — sets the API host
  customerId: '...',        // required — your Flinks customer GUID
  secretKey: '...',         // mints authorize tokens (flinks-auth-key)
  xApiKey: '...',           // data-endpoint auth (x-api-key)
  hmacSecret: '...',        // verify inbound webhooks
  payClientId: '...',       // optional — Flinks Pay x-client-id (EFT V1)
  authorizeToken: '...',    // optional — reuse a token instead of minting
  timeoutMs: 60_000,        // per-request timeout (default 60s)
  maxRetries: 2,            // transient-failure retries (default 2)
  fetch: customFetch,       // inject your own fetch (testing, proxies)
  hosts: { ... },           // override base hosts (self-hosted / testing)
});

🧪 Test against your sandbox

The default bun run test suite is fully offline. The live suite hits Flinks' public sandbox with no setup:

bun run test:sandbox        # runs against the public Toolbox sandbox

Point it at your own instance by overriding env vars:

FLINKS_INSTANCE=toolbox \
FLINKS_CUSTOMER_ID=your-customer-guid \
FLINKS_SECRET_KEY=your-secret-key \
FLINKS_X_API_KEY=your-x-api-key \
bun run test:sandbox

🛠️ Development

bun install
bun run typecheck   # tsc --noEmit
bun run test        # vitest (offline)
bun run build       # tsup → dist (ESM + CJS + d.ts)

🤝 Contributing

Bug fixes, new endpoints, and docs are all welcome — see CONTRIBUTING.md. CI must pass (typecheck · build · test).

📄 License

MIT © Rafey. Community project — not affiliated with Flinks.

About

Unofficial, fully-typed Flinks API client for Node.js & Bun — every product (Connect, Authorize, Enrich, Identity, Upload, Pay, Open Banking), zero dependencies

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages